refactor(realism): move emailgen LLM/personas/prompt into shared library

Lift the format-agnostic pieces from decnet/orchestrator/emailgen/
into the new decnet/realism/ library so file-class content generation
(stage 3 of the realism migration) can reuse them. Email-specific
delivery (RFC 2822 EML, IMAP/POP3 spool, thread chains) stays in
orchestrator/.

Renames (history-preserving git mv):
  emailgen/personas.py     -> realism/personas.py
  emailgen/prompt.py       -> realism/prompts/email.py
  emailgen/global_pool.py  -> realism/personas_pool.py
  emailgen/llm/            -> realism/llm/

Env-var clean break (pre-v1, no aliases):
  DECNET_EMAILGEN_LLM      -> DECNET_REALISM_LLM
  DECNET_EMAILGEN_MODEL    -> DECNET_REALISM_MODEL
  DECNET_EMAILGEN_TIMEOUT  -> DECNET_REALISM_TIMEOUT
  DECNET_EMAILGEN_PERSONAS -> DECNET_REALISM_PERSONAS
  DECNET_EMAILGEN_FAKE_OUTPUT -> DECNET_REALISM_FAKE_OUTPUT

Importers rewritten in: orchestrator/emailgen/scheduler.py,
orchestrator/drivers/email.py, web/router/{emailgen,topology}/
api_personas.py, cli/emailgen.py. Tests for moved modules relocated
to tests/realism/; tests for stay-put modules updated in place.

API URL `/api/v1/emailgen/personas` and CLI `decnet emailgen
import-personas` keep their public names until the service-collapse
commit (stage 5).
This commit is contained in:
2026-04-27 16:05:43 -04:00
parent f57c621117
commit 0b9873982d
34 changed files with 455 additions and 298 deletions

View File

@@ -122,8 +122,8 @@ def register(app: typer.Typer) -> None:
this command does not touch them.
"""
_require_master_mode("emailgen import-personas")
from decnet.orchestrator.emailgen import global_pool
from decnet.orchestrator.emailgen.personas import parse_personas
from decnet.realism import personas_pool as global_pool
from decnet.realism.personas import parse_personas
try:
raw = path.read_text(encoding="utf-8")

View File

@@ -5,9 +5,9 @@ configured emailgen spool directory (``/var/spool/decnet-emails/`` by
default). The IMAP/POP3 service templates read that spool at request
time so attackers see the generated mail in their MUA.
The LLM call goes through :mod:`decnet.orchestrator.emailgen.llm` —
backend-agnostic by construction so swapping Ollama for the Anthropic
API, vLLM, or llama.cpp is a config change, not a driver rewrite.
The LLM call goes through :mod:`decnet.realism.llm` — backend-agnostic
by construction so swapping Ollama for the Anthropic API, vLLM, or
llama.cpp is a config change, not a driver rewrite.
Output is parsed-and-repaired into a valid EML using
:mod:`email.mime.*`; the worker then ``docker exec``\\s a ``tee`` to
drop the file inside the target container, followed by a
@@ -29,10 +29,10 @@ from typing import Any, Optional
from decnet.logging import get_logger
from decnet.orchestrator.drivers.base import ActivityResult
from decnet.orchestrator.emailgen.llm import LLMBackend, LLMTimeout, get_llm
from decnet.orchestrator.emailgen.prompt import PromptInputs, build as build_prompt
from decnet.orchestrator.emailgen.scheduler import EmailAction
from decnet.orchestrator.emailgen.threads import new_message_id
from decnet.realism.llm import LLMBackend, LLMTimeout, get_llm
from decnet.realism.prompts.email import PromptInputs, build as build_prompt
log = get_logger("orchestrator.email")

View File

@@ -11,7 +11,7 @@ heartbeat / control-listener scaffolding via :mod:`decnet.bus.publish`.
Lazy worker re-export: :func:`emailgen_worker` is loaded on first
attribute access so that submodules can import package-level names
(``decnet.orchestrator.emailgen.prompt``) without triggering an eager
(``decnet.orchestrator.emailgen.events``) without triggering an eager
load of the worker — and through it, the email driver, which imports
back into this package. Without lazy loading the package + driver +
worker form a cycle.

View File

@@ -1,22 +0,0 @@
"""LLM backend for emailgen.
Pluggable from day one (per the provider-subpackages convention used by
:mod:`decnet.web.db` and :mod:`decnet.bus`): the worker only depends on
:class:`LLMBackend` from :mod:`base`; concrete transports live under
:mod:`impl` and are selected by :func:`get_llm`.
This is the seam ANTI will pull on when swapping local Ollama for the
Anthropic API, llama.cpp, vLLM, or any other inference server — change
``DECNET_EMAILGEN_LLM`` (or pass ``llm=`` to the driver), no driver
rewrite.
"""
from __future__ import annotations
from decnet.orchestrator.emailgen.llm.base import (
LLMBackend,
LLMResult,
LLMTimeout,
)
from decnet.orchestrator.emailgen.llm.factory import get_llm
__all__ = ["LLMBackend", "LLMResult", "LLMTimeout", "get_llm"]

View File

@@ -1,6 +0,0 @@
"""Concrete LLM-backend implementations.
Importers go through :func:`decnet.orchestrator.emailgen.llm.get_llm`,
not these modules directly — same convention as
:mod:`decnet.web.db.sqlite` and :mod:`decnet.bus.unix_client`.
"""

View File

@@ -1,119 +0,0 @@
"""Persona schema for the emailgen worker.
Stored as a JSON list on :attr:`Topology.email_personas`. Each persona
describes one fictional employee whose mailbox lives on the topology's
IMAP/POP3 decky. The schema deliberately stays narrow: the LLM gets
*enough* differentiation to write distinct voices, no more.
Invalid entries are dropped with a warning (returned alongside the
parsed list) rather than raising — a single typo in one persona must
not stall the entire emailgen tick.
"""
from __future__ import annotations
import json
from typing import Literal, Optional
from pydantic import BaseModel, Field, ValidationError, field_validator
from decnet.logging import get_logger
logger = get_logger("orchestrator.emailgen")
Tone = Literal["formal", "direct", "casual", "technical"]
ReplyLatency = Literal["fast", "normal", "slow"]
class EmailPersona(BaseModel):
"""One fake mailbox owner.
``language`` is ISO 639-1 (``en``, ``es``, ``pt``…); when unset on the
persona it falls back to the topology's ``language_default``.
``uses_llms_heavily`` lifts the prompt-layer em-dash suppression for
that persona — em-dashes are an LLM tell, but a persona explicitly
pegged as a heavy LLM user should *naturally* produce them.
"""
name: str = Field(min_length=1, max_length=128)
email: str = Field(min_length=3, max_length=255)
role: str = Field(min_length=1, max_length=128)
tone: Tone = "formal"
mannerisms: list[str] = Field(default_factory=list, max_length=12)
language: Optional[str] = Field(default=None, max_length=8)
signature: Optional[str] = Field(default=None, max_length=512)
active_hours: str = Field(default="09:00-18:00", max_length=32)
reply_latency: ReplyLatency = "normal"
uses_llms_heavily: bool = False
@field_validator("email")
@classmethod
def _email_shape(cls, v: str) -> str:
# Cheap structural check — full RFC 5322 isn't worth the
# dependency. We only need ``user@domain`` with non-empty parts
# for the prompt builder + Message-ID generator.
if "@" not in v:
raise ValueError("email must contain '@'")
local, _, domain = v.rpartition("@")
if not local or not domain or "." not in domain:
raise ValueError("email must look like user@domain.tld")
return v
def parse_personas(
raw: str | list | None,
*,
language_default: str = "en",
) -> list[EmailPersona]:
"""Parse the JSON-or-list ``email_personas`` value into models.
Resolves ``language`` against *language_default* so downstream
consumers (prompt builder, scheduler) never need to know about
fallback semantics.
"""
if not raw:
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning("emailgen personas: invalid JSON, skipping: %s", exc)
return []
if not isinstance(raw, list):
logger.warning(
"emailgen personas: expected list, got %s", type(raw).__name__
)
return []
out: list[EmailPersona] = []
for i, entry in enumerate(raw):
try:
persona = EmailPersona.model_validate(entry)
except ValidationError as exc:
logger.warning(
"emailgen personas: dropping invalid entry index=%d: %s",
i, exc.errors(include_url=False),
)
continue
if persona.language is None:
persona = persona.model_copy(update={"language": language_default})
out.append(persona)
return out
def in_active_hours(persona: EmailPersona, now_hour: int) -> bool:
"""Return True if *now_hour* (023) falls in the persona's window.
Format: ``"HH:MM-HH:MM"``. Wrap-around windows (``"22:00-06:00"``)
are supported. Invalid windows treat the persona as always-on so a
config typo never silences the whole fleet.
"""
try:
start_s, end_s = persona.active_hours.split("-")
start_h = int(start_s.split(":")[0])
end_h = int(end_s.split(":")[0])
except (ValueError, IndexError):
return True
if start_h == end_h:
return True
if start_h < end_h:
return start_h <= now_hour < end_h
# Wrap-around (e.g. 22:00-06:00).
return now_hour >= start_h or now_hour < end_h

View File

@@ -25,18 +25,18 @@ from datetime import datetime
from typing import Any, Optional
from decnet.logging import get_logger
from decnet.orchestrator.emailgen import global_pool
from decnet.orchestrator.emailgen.personas import (
EmailPersona,
in_active_hours,
parse_personas,
)
from decnet.orchestrator.emailgen.threads import (
ThreadChain,
new_thread_id,
references_for_reply,
reply_subject,
)
from decnet.realism import personas_pool as global_pool
from decnet.realism.personas import (
EmailPersona,
in_active_hours,
parse_personas,
)
logger = get_logger("orchestrator.emailgen")

View File

@@ -13,8 +13,8 @@ import from here. This package owns:
``mtime`` sampler so planted files don't all stamp at wall-clock-now.
* :mod:`decnet.realism.planner` — picks ``(decky, persona, class,
action, mtime)`` tuples for the orchestrator's tick loop.
* :mod:`decnet.realism.personas` — persona schema (lifted from
``orchestrator.emailgen.personas`` in stage 2 of the migration).
* :mod:`decnet.realism.personas` — persona schema (the
:class:`EmailPersona` record describing each fictional employee).
* :mod:`decnet.realism.prompts` — prompt builders, one per content
class, sharing an em-dash-suppression style helper.
* :mod:`decnet.realism.llm` — :class:`LLMBackend` ABC + factory + impl

View File

@@ -38,8 +38,7 @@ def _parse_window(window: str) -> tuple[int, int, int, int] | None:
Returns ``None`` for malformed input — callers treat that as
"always-on" so a single config typo never silences the whole fleet
(mirrors :func:`decnet.orchestrator.emailgen.personas.in_active_hours`
semantics).
(mirrors :func:`decnet.realism.personas.in_active_hours` semantics).
"""
try:
start_s, end_s = window.split("-")

View File

@@ -1,8 +1,17 @@
"""LLM backend ABC + factory + impls.
"""LLM backend for the realism library.
Populated in stage 2 of the realism migration: lifts the existing
``orchestrator.emailgen.llm`` subpackage as-is (``base``, ``factory``,
``impl/ollama``, ``impl/fake``). Stage 6 adds ``circuit.py`` for
cross-call breaker behaviour.
Pluggable per the provider-subpackages convention (mirrors
:mod:`decnet.web.db` and :mod:`decnet.bus`): consumers depend on
:class:`LLMBackend` from :mod:`base`; concrete transports live under
:mod:`impl` and are selected by :func:`get_llm`.
This is the seam to pull on when swapping local Ollama for the
Anthropic API, llama.cpp, vLLM, or any other inference server — change
``DECNET_REALISM_LLM`` (or pass ``llm=`` directly), no caller rewrite.
"""
from __future__ import annotations
from decnet.realism.llm.base import LLMBackend, LLMResult, LLMTimeout
from decnet.realism.llm.factory import get_llm
__all__ = ["LLMBackend", "LLMResult", "LLMTimeout", "get_llm"]

View File

@@ -1,11 +1,11 @@
"""Backend protocol shared by every LLM transport.
Deliberately narrow: emailgen needs one async ``generate`` call that
takes a prompt string and returns the model's output text plus enough
metadata for the worker to populate the orchestrator-email payload
(model name, latency, success bit). Streaming, embeddings, multi-turn
chat all out of scope here; emailgen only ever does one-shot
single-prompt generations.
Deliberately narrow: realism consumers need one async ``generate``
call that takes a prompt string and returns the model's output text
plus enough metadata to populate per-event payloads (model name,
latency, success bit). Streaming, embeddings, multi-turn chat all
out of scope here; realism only ever does one-shot single-prompt
generations.
"""
from __future__ import annotations
@@ -39,7 +39,7 @@ class LLMResult:
class LLMBackend(Protocol):
"""Minimal contract for an emailgen LLM provider."""
"""Minimal contract for a realism LLM provider."""
model: str
timeout: float

View File

@@ -1,17 +1,17 @@
"""Backend dispatch.
Reads ``DECNET_EMAILGEN_LLM`` to pick a concrete :class:`LLMBackend`.
Reads ``DECNET_REALISM_LLM`` to pick a concrete :class:`LLMBackend`.
Defaults to ``ollama`` because that's what the prototype proved out and
what most dev boxes have on hand.
Supported keys:
* ``ollama`` :class:`decnet.orchestrator.emailgen.llm.impl.ollama.OllamaBackend`
* ``fake`` :class:`decnet.orchestrator.emailgen.llm.impl.fake.FakeBackend`
* ``ollama`` :class:`decnet.realism.llm.impl.ollama.OllamaBackend`
* ``fake`` :class:`decnet.realism.llm.impl.fake.FakeBackend`
(canned output, used by tests so they don't shell out)
Anthropic / vLLM / llama.cpp slots in here as a third branch when the
need shows up. Per the provider-subpackages memory, do NOT collapse
need shows up. Per the provider-subpackages convention, do NOT collapse
factory dispatch into the impl modules keeps the ``__init__`` import
graph cycle-free and the env contract auditable in one place.
"""
@@ -20,27 +20,27 @@ from __future__ import annotations
import os
from typing import Any
from decnet.orchestrator.emailgen.llm.base import LLMBackend
from decnet.realism.llm.base import LLMBackend
def get_llm(*, model: str | None = None, **kwargs: Any) -> LLMBackend:
"""Instantiate the LLM backend selected by environment.
*model* (when provided) overrides whatever the backend's own default
is e.g. for OllamaBackend that's ``llama3.1`` unless
``DECNET_EMAILGEN_MODEL`` says otherwise. Lets the worker honour
``decnet emailgen run --model gpt-oss`` without each backend having
is e.g. for :class:`OllamaBackend` that's ``llama3.1`` unless
``DECNET_REALISM_MODEL`` says otherwise. Lets the worker honour
``decnet orchestrate --model gpt-oss`` without each backend having
to know about CLI flags.
"""
backend_key = os.environ.get("DECNET_EMAILGEN_LLM", "ollama").lower()
backend_key = os.environ.get("DECNET_REALISM_LLM", "ollama").lower()
if backend_key == "ollama":
from decnet.orchestrator.emailgen.llm.impl.ollama import OllamaBackend
from decnet.realism.llm.impl.ollama import OllamaBackend
return OllamaBackend(model=model, **kwargs)
if backend_key == "fake":
from decnet.orchestrator.emailgen.llm.impl.fake import FakeBackend
from decnet.realism.llm.impl.fake import FakeBackend
return FakeBackend(model=model or "fake-model", **kwargs)
raise ValueError(
f"Unsupported DECNET_EMAILGEN_LLM={backend_key!r}; "
f"Unsupported DECNET_REALISM_LLM={backend_key!r}; "
"expected one of: ollama, fake"
)

View File

@@ -0,0 +1,6 @@
"""Concrete LLM-backend implementations.
Importers go through :func:`decnet.realism.llm.get_llm`, not these
modules directly — same convention as :mod:`decnet.web.db.sqlite` and
:mod:`decnet.bus.unix_client`.
"""

View File

@@ -1,9 +1,9 @@
"""In-process fake backend for tests.
Returns a canned ``Subject:\\n\\nbody`` string so the driver path can be
exercised without an Ollama install. Configurable via ``DECNET_EMAILGEN_FAKE_OUTPUT``
(env) or the ``output`` constructor arg the env-var path lets
integration tests run the worker end-to-end with deterministic output.
Returns a canned string so the driver path can be exercised without an
Ollama install. Configurable via ``DECNET_REALISM_FAKE_OUTPUT`` (env)
or the ``output`` constructor arg the env-var path lets integration
tests run the worker end-to-end with deterministic output.
"""
from __future__ import annotations
@@ -11,7 +11,7 @@ import os
import time
from typing import Optional
from decnet.orchestrator.emailgen.llm.base import LLMBackend, LLMResult
from decnet.realism.llm.base import LLMBackend, LLMResult
_DEFAULT_OUTPUT = (
@@ -34,7 +34,7 @@ class FakeBackend(LLMBackend):
self._output = (
output
if output is not None
else os.environ.get("DECNET_EMAILGEN_FAKE_OUTPUT", _DEFAULT_OUTPUT)
else os.environ.get("DECNET_REALISM_FAKE_OUTPUT", _DEFAULT_OUTPUT)
)
self._success = success

View File

@@ -1,9 +1,6 @@
"""Ollama subprocess backend.
Shells out to ``ollama run <model>`` with the prompt fed via stdin.
Mirrors what the original prototype at ``DECNET-EMAILs/main.py`` did,
but lifted out of the driver so the rest of emailgen never imports a
specific transport.
Why subprocess and not the Ollama HTTP API:
* No new dependency (``ollama`` Python lib is optional).
@@ -13,9 +10,9 @@ Why subprocess and not the Ollama HTTP API:
to debug discrepancies between worker output and a console session.
Cost: per-call process spawn (~50ms on a warm box). Acceptable for
emailgen's tick rate (one email every 5 minutes by default). When that
cost matters, swap to an HTTP-API backend; the seam is in
:mod:`decnet.orchestrator.emailgen.llm.factory`.
realism tick rates (one body per ~5 minutes per persona by default).
When that cost matters, swap to an HTTP-API backend; the seam is in
:mod:`decnet.realism.llm.factory`.
"""
from __future__ import annotations
@@ -25,17 +22,13 @@ import time
from typing import Optional
from decnet.logging import get_logger
from decnet.orchestrator.emailgen.llm.base import (
LLMBackend,
LLMResult,
LLMTimeout,
)
from decnet.realism.llm.base import LLMBackend, LLMResult, LLMTimeout
log = get_logger("orchestrator.emailgen.llm")
log = get_logger("realism.llm")
_OLLAMA = "ollama"
_DEFAULT_MODEL = os.environ.get("DECNET_EMAILGEN_MODEL", "llama3.1")
_DEFAULT_TIMEOUT = float(os.environ.get("DECNET_EMAILGEN_TIMEOUT", "60"))
_DEFAULT_MODEL = os.environ.get("DECNET_REALISM_MODEL", "llama3.1")
_DEFAULT_TIMEOUT = float(os.environ.get("DECNET_REALISM_TIMEOUT", "60"))
class OllamaBackend(LLMBackend):

View File

@@ -1,9 +1,138 @@
"""Persona schema — placeholder for stage 2.
"""Persona schema for realism content generation.
In stage 2 of the realism migration, this module receives the real
persona schema currently living at
``decnet.orchestrator.emailgen.personas`` (``EmailPersona``,
``parse_personas``, ``in_active_hours``). Stage 1 keeps it empty so
the import path is reserved without behaviour.
Stored as a JSON list on :attr:`Topology.email_personas`. Each persona
describes one fictional employee — sender of email *and* author of
files (notes, TODOs, drafts, scripts) on the deckies they're sampled
onto. The schema deliberately stays narrow: the LLM gets *enough*
differentiation to write distinct voices, no more.
The class is still named :class:`EmailPersona` because every persona
in the pool today carries a mandatory email address (used for IMAP/
POP3 spool delivery). Future per-decky personas without mailboxes
would justify a rename / superclass; not in scope for the realism
migration.
Invalid entries are dropped with a warning (returned alongside the
parsed list) rather than raising — a single typo in one persona must
not stall the entire realism tick.
"""
from __future__ import annotations
import json
from typing import Literal, Optional
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from decnet.logging import get_logger
logger = get_logger("realism.personas")
Tone = Literal["formal", "direct", "casual", "technical", "custom"]
ReplyLatency = Literal["fast", "normal", "slow"]
class EmailPersona(BaseModel):
"""One fake mailbox owner.
``language`` is ISO 639-1 (``en``, ``es``, ``pt``…); when unset on the
persona it falls back to the topology's ``language_default``.
``uses_llms_heavily`` lifts the prompt-layer em-dash suppression for
that persona — em-dashes are an LLM tell, but a persona explicitly
pegged as a heavy LLM user should *naturally* produce them.
"""
name: str = Field(min_length=1, max_length=128)
email: str = Field(min_length=3, max_length=255)
role: str = Field(min_length=1, max_length=128)
tone: Tone = "formal"
tone_custom: Optional[str] = Field(default=None, max_length=128)
mannerisms: list[str] = Field(default_factory=list, max_length=12)
language: Optional[str] = Field(default=None, max_length=8)
signature: Optional[str] = Field(default=None, max_length=512)
active_hours: str = Field(default="09:00-18:00", max_length=32)
reply_latency: ReplyLatency = "normal"
uses_llms_heavily: bool = False
@model_validator(mode="after")
def _custom_tone_requires_text(self) -> "EmailPersona":
# ``tone="custom"`` lets operators describe a voice the four canned
# tones don't capture (sarcastic, deadpan, terse, etc.). The free
# text is interpolated into the prompt verbatim, so an empty
# value would just leave the LLM with the literal word "custom" —
# reject it loudly instead of silently producing a useless prompt.
if self.tone == "custom" and not (self.tone_custom and self.tone_custom.strip()):
raise ValueError("tone_custom is required when tone is 'custom'")
return self
@field_validator("email")
@classmethod
def _email_shape(cls, v: str) -> str:
# Cheap structural check — full RFC 5322 isn't worth the
# dependency. We only need ``user@domain`` with non-empty parts
# for the prompt builder + Message-ID generator.
if "@" not in v:
raise ValueError("email must contain '@'")
local, _, domain = v.rpartition("@")
if not local or not domain or "." not in domain:
raise ValueError("email must look like user@domain.tld")
return v
def parse_personas(
raw: str | list | None,
*,
language_default: str = "en",
) -> list[EmailPersona]:
"""Parse the JSON-or-list ``email_personas`` value into models.
Resolves ``language`` against *language_default* so downstream
consumers (prompt builder, scheduler) never need to know about
fallback semantics.
"""
if not raw:
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning("realism personas: invalid JSON, skipping: %s", exc)
return []
if not isinstance(raw, list):
logger.warning(
"realism personas: expected list, got %s", type(raw).__name__
)
return []
out: list[EmailPersona] = []
for i, entry in enumerate(raw):
try:
persona = EmailPersona.model_validate(entry)
except ValidationError as exc:
logger.warning(
"realism personas: dropping invalid entry index=%d: %s",
i, exc.errors(include_url=False),
)
continue
if persona.language is None:
persona = persona.model_copy(update={"language": language_default})
out.append(persona)
return out
def in_active_hours(persona: EmailPersona, now_hour: int) -> bool:
"""Return True if *now_hour* (023) falls in the persona's window.
Format: ``"HH:MM-HH:MM"``. Wrap-around windows (``"22:00-06:00"``)
are supported. Invalid windows treat the persona as always-on so a
config typo never silences the whole fleet.
"""
try:
start_s, end_s = persona.active_hours.split("-")
start_h = int(start_s.split(":")[0])
end_h = int(end_s.split(":")[0])
except (ValueError, IndexError):
return True
if start_h == end_h:
return True
if start_h < end_h:
return start_h <= now_hour < end_h
# Wrap-around (e.g. 22:00-06:00).
return now_hour >= start_h or now_hour < end_h

View File

@@ -1,34 +1,36 @@
"""Global persona pool — non-topology mail deckies.
"""Global persona pool — non-topology deckies.
DECNET runs in three deployment shapes that emit running deckies:
* **MazeNET topologies** each topology owns its own
:attr:`Topology.email_personas` JSON list; the scheduler walks back
from the mail decky to its parent topology row.
:attr:`Topology.email_personas` JSON list; consumers walk from the
decky back to its parent topology row.
* **Unihost fleet** MACVLAN/IPVLAN deckies that have no
parent topology row at all. They share one host-wide pool.
* **SWARM shards** DeckyShard rows on enrolled workers.
Same shape as fleet for emailgen purposes (no parent topology row),
Same shape as fleet for realism purposes (no parent topology row),
so they read the same global pool.
This module owns the global pool: a JSON file on disk that operators
populate via ``decnet emailgen import-personas <file>`` (or by editing
populate via ``decnet realism import-personas <file>`` (or by editing
the file directly). The file is loaded lazily on first read and
re-loaded on mtime change so a CLI import takes effect for the running
worker without a restart.
Path resolution order:
1. ``DECNET_EMAILGEN_PERSONAS`` environment variable explicit override.
1. ``DECNET_REALISM_PERSONAS`` environment variable explicit override.
2. ``/etc/decnet/email_personas.json`` canonical master path; this is
what ``decnet init`` will eventually own.
what ``decnet init`` will eventually own. Filename retained
(``email_personas.json``) because the on-disk schema hasn't changed
and operators may already have committed copies.
3. ``~/.decnet/email_personas.json`` dev fallback so a developer can
exercise the worker without root or ``decnet init``.
exercise consumers without root or ``decnet init``.
When the file is missing / empty / unparseable, the pool is empty and
the scheduler skips fleet/shard mail deckies the same way it skips a
topology with too few personas. No silent fallback to dummy personas;
silence is correct when there's no opinion to convey.
consumers skip fleet/shard deckies the same way they skip a topology
with too few personas. No silent fallback to dummy personas; silence
is correct when there's no opinion to convey.
"""
from __future__ import annotations
@@ -38,11 +40,11 @@ from pathlib import Path
from typing import Optional
from decnet.logging import get_logger
from decnet.orchestrator.emailgen.personas import EmailPersona, parse_personas
from decnet.realism.personas import EmailPersona, parse_personas
logger = get_logger("orchestrator.emailgen")
logger = get_logger("realism.personas_pool")
_ENV_VAR = "DECNET_EMAILGEN_PERSONAS"
_ENV_VAR = "DECNET_REALISM_PERSONAS"
_SYSTEM_PATH = Path("/etc/decnet/email_personas.json")
@@ -54,13 +56,20 @@ def resolve_path() -> Path:
"""Return the path the global pool would load from right now.
The file may not exist; callers are expected to handle that. The
function is pure (no I/O) so the ``decnet emailgen import-personas``
function is pure (no I/O) so the ``decnet realism import-personas``
CLI can ask "where would I write to?" without touching the disk.
"""
override = os.environ.get(_ENV_VAR, "").strip()
if override:
return Path(override)
if _SYSTEM_PATH.parent.exists() or _SYSTEM_PATH.exists():
if _SYSTEM_PATH.exists():
return _SYSTEM_PATH
# ``/etc/decnet`` exists on a fully-provisioned host (post ``decnet
# init``) but may be read-only for the API user on dev boxes — fall
# back to the user path when the directory isn't writable so a fresh
# PUT lands somewhere instead of erroring out. We only do this when
# the system file doesn't exist yet; once it does, it's authoritative.
if _SYSTEM_PATH.parent.exists() and os.access(_SYSTEM_PATH.parent, os.W_OK):
return _SYSTEM_PATH
return _user_path()
@@ -108,7 +117,7 @@ def load(*, language_default: str = "en") -> list[EmailPersona]:
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("emailgen global pool: read failed path=%s: %s", path, exc)
logger.warning("realism global pool: read failed path=%s: %s", path, exc)
return []
parsed = parse_personas(raw, language_default=language_default)
@@ -118,7 +127,7 @@ def load(*, language_default: str = "en") -> list[EmailPersona]:
_cache_mtime = st.st_mtime
if parsed:
logger.info(
"emailgen global pool: loaded %d personas from %s", len(parsed), path,
"realism global pool: loaded %d personas from %s", len(parsed), path,
)
return parsed

View File

@@ -1,7 +1,9 @@
"""Prompt builders for LLM-enriched content.
Populated in stage 2 (``email.py`` lifted from
``orchestrator.emailgen.prompt``) and stage 6 (``filebody.py``,
``filename.py``, ``_style.py`` for em-dash suppression).
* :mod:`decnet.realism.prompts.email` — corporate-email body builder.
Stage 6 of the realism migration adds ``filebody.py``, ``filename.py``,
and a ``_style.py`` helper so em-dash suppression sits in one place
across email + file-class prompts.
"""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""Ollama prompt builder for emailgen.
"""Prompt builder for the email content class.
The LLM gets a tightly-scoped instruction and a small handful of
deterministic constraints. Persona mannerisms are *pre-selected* in
@@ -9,7 +9,10 @@ ignore it, and the corpus collapses into one voice.
**Em-dash suppression** is on by default; suppression is lifted only
for personas that opt in via ``uses_llms_heavily``. Em-dashes are a
strong stylometric tell for LLM-authored prose, and a honeypot mailbox
where every author uses them is a tell.
where every author uses them is a tell. Stage 6 of the realism
migration extracts the suppression block into a shared
``decnet.realism.prompts._style`` helper so file-class prompts pick
it up too.
"""
from __future__ import annotations
@@ -17,7 +20,7 @@ import secrets
from dataclasses import dataclass
from typing import Optional
from decnet.orchestrator.emailgen.personas import EmailPersona
from decnet.realism.personas import EmailPersona
@dataclass(frozen=True)
@@ -128,7 +131,7 @@ def build(
Persona sender:
- Name: {sender.name}
- Role: {sender.role}
- Tone: {sender.tone}
- Tone: {sender.tone_custom if sender.tone == "custom" and sender.tone_custom else sender.tone}
- Mannerisms (must show through):
{mannerism_block}

View File

@@ -113,7 +113,7 @@ 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).
# JSON file directly (see decnet.realism.personas_pool).
api_router.include_router(emailgen_personas_router)
# Observability

View File

@@ -1,10 +1,10 @@
"""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.
The "global pool" is a JSON file consumed by the realism content
engine for fleet (MACVLAN/IPVLAN) and SWARM-shard deckies — see
:mod:`decnet.realism.personas_pool`. MazeNET topology 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
@@ -22,8 +22,8 @@ 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.realism import personas_pool as global_pool
from decnet.realism.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
@@ -110,11 +110,28 @@ async def replace_personas(
)
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",
)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(_serialize(parsed), indent=2, ensure_ascii=False),
encoding="utf-8",
)
except OSError as exc:
# Most common cause on dev boxes: ``/etc/decnet`` exists but is
# not writable by the API process. Surface a 500 with the
# actionable hint instead of leaking a traceback.
log.warning(
"api.emailgen.replace_personas write failed path=%s err=%s",
dest, exc,
)
raise HTTPException(
status_code=500,
detail=(
f"Could not write persona pool at {dest}: {exc.strerror or exc}. "
f"Set DECNET_EMAILGEN_PERSONAS to a writable path "
f"(e.g. ~/.decnet/email_personas.json) and restart the API."
),
) from exc
global_pool.reset_cache()
log.info(
"api.emailgen.replace_personas user=%s wrote=%d path=%s",

View File

@@ -21,6 +21,7 @@ from .api_get_topology import router as _get_router
from .api_lan_crud import router as _lan_router
from .api_list_topologies import router as _list_router
from .api_mutations import router as _mutations_router
from .api_personas import router as _personas_router
from .api_reap_orphans import router as _reap_router
from .api_teardown_topology import router as _teardown_router
@@ -44,6 +45,10 @@ topology_router.include_router(_decky_router)
topology_router.include_router(_edge_router)
topology_router.include_router(_mutations_router)
topology_router.include_router(_events_router)
# Personas use a literal-suffix path (`/{id}/personas`) — register
# before the bare `/{id}` getter so FastAPI's trie sees the literal
# segment first.
topology_router.include_router(_personas_router)
topology_router.include_router(_get_router)

View File

@@ -0,0 +1,131 @@
"""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.get("language_default") or "en"
personas = parse_personas(
topo.get("email_personas"), language_default=language_default,
)
return {
"topology_id": topology_id,
"topology_name": topo.get("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": [<EmailPersona>, ...]}``.
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.get("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.get("name", ""),
"language_default": language_default,
"personas": serialized,
}

View File

@@ -5,7 +5,7 @@ import json
import pytest
from decnet.orchestrator.emailgen import global_pool
from decnet.realism import personas_pool as global_pool
from decnet.web.router.emailgen.api_personas import (
list_personas,
replace_personas,
@@ -40,7 +40,7 @@ _VALID = [
@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"),
"DECNET_REALISM_PERSONAS", str(tmp_path / "missing.json"),
)
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert result["personas"] == []
@@ -51,7 +51,7 @@ async def test_list_returns_empty_when_no_pool(tmp_path, monkeypatch):
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))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool))
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert len(result["personas"]) == 2
@@ -63,7 +63,7 @@ async def test_list_returns_existing_pool(tmp_path, monkeypatch):
@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))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": _VALID},
@@ -83,7 +83,7 @@ async def test_replace_with_empty_list_clears_pool(tmp_path, monkeypatch):
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))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": []},
@@ -98,7 +98,7 @@ 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"),
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
@@ -116,7 +116,7 @@ async def test_replace_rejects_all_invalid_payload(tmp_path, monkeypatch):
from fastapi import HTTPException
monkeypatch.setenv(
"DECNET_EMAILGEN_PERSONAS", str(tmp_path / "pool.json"),
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
@@ -132,7 +132,7 @@ 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))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": [
@@ -153,7 +153,7 @@ 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))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
await replace_personas(
body={"personas": _VALID},

View File

@@ -7,7 +7,7 @@ import pytest
from typer.testing import CliRunner
from decnet.cli import app
from decnet.orchestrator.emailgen import global_pool
from decnet.realism import personas_pool as global_pool
@pytest.fixture(autouse=True)
@@ -39,7 +39,7 @@ def test_import_personas_writes_canonical_file(tmp_path, monkeypatch):
src = tmp_path / "src.json"
src.write_text(json.dumps(_TWO))
dest = tmp_path / "global_pool.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = CliRunner().invoke(
app, ["emailgen", "import-personas", str(src)]
@@ -55,7 +55,7 @@ def test_import_personas_explicit_output_overrides_env(tmp_path, monkeypatch):
src.write_text(json.dumps(_TWO))
env_dest = tmp_path / "env.json"
explicit = tmp_path / "explicit.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(env_dest))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(env_dest))
result = CliRunner().invoke(
app,
@@ -79,7 +79,7 @@ def test_import_personas_rejects_invalid_json(tmp_path):
def test_import_personas_rejects_non_list(tmp_path, monkeypatch):
src = tmp_path / "src.json"
src.write_text(json.dumps({"not": "a list"}))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(tmp_path / "out.json"))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "out.json"))
result = CliRunner().invoke(
app, ["emailgen", "import-personas", str(src)]
)
@@ -92,7 +92,7 @@ def test_import_personas_rejects_all_invalid_entries(tmp_path, monkeypatch):
src.write_text(json.dumps([
{"name": "broken", "email": "no-at-symbol"},
]))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(tmp_path / "out.json"))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "out.json"))
result = CliRunner().invoke(
app, ["emailgen", "import-personas", str(src)]
)
@@ -104,7 +104,7 @@ def test_import_personas_warns_on_single_persona(tmp_path, monkeypatch):
src = tmp_path / "src.json"
src.write_text(json.dumps(_TWO[:1]))
dest = tmp_path / "out.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = CliRunner().invoke(
app, ["emailgen", "import-personas", str(src)]
)
@@ -117,7 +117,7 @@ def test_imported_personas_load_via_global_pool(tmp_path, monkeypatch):
src = tmp_path / "src.json"
src.write_text(json.dumps(_TWO))
dest = tmp_path / "out.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
result = CliRunner().invoke(
app, ["emailgen", "import-personas", str(src)]

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
import pytest
from decnet.orchestrator.drivers import email as email_driver
from decnet.orchestrator.emailgen.llm.base import LLMResult, LLMTimeout
from decnet.orchestrator.emailgen.llm.impl.fake import FakeBackend
from decnet.orchestrator.emailgen.personas import EmailPersona
from decnet.orchestrator.emailgen.scheduler import EmailAction
from decnet.realism.llm.base import LLMResult, LLMTimeout
from decnet.realism.llm.impl.fake import FakeBackend
from decnet.realism.personas import EmailPersona
class _RaisingBackend:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from decnet.bus import topics as _topics
from decnet.orchestrator.drivers.base import ActivityResult
from decnet.orchestrator.emailgen import events
from decnet.orchestrator.emailgen.personas import EmailPersona
from decnet.realism.personas import EmailPersona
from decnet.orchestrator.emailgen.scheduler import EmailAction

View File

@@ -7,7 +7,8 @@ from typing import Any
import pytest
from decnet.orchestrator.emailgen import global_pool, scheduler
from decnet.orchestrator.emailgen import scheduler
from decnet.realism import personas_pool as global_pool
@pytest.fixture(autouse=True)
@@ -147,7 +148,7 @@ async def test_pick_for_fleet_source_uses_global_pool(tmp_path, monkeypatch):
personas come from the host-wide JSON file."""
pool_file = tmp_path / "personas.json"
pool_file.write_text(json.dumps(_PERSONAS_TWO))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(pool_file))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool_file))
repo = _FakeRepo(
deckies=[_decky(source="fleet", topology_id=None)],
@@ -163,7 +164,7 @@ async def test_pick_for_shard_source_uses_global_pool(tmp_path, monkeypatch):
"""SWARM shards are non-topology too — same path as fleet."""
pool_file = tmp_path / "personas.json"
pool_file.write_text(json.dumps(_PERSONAS_TWO))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(pool_file))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool_file))
repo = _FakeRepo(
deckies=[_decky(source="shard", topology_id=None)],
@@ -174,7 +175,7 @@ async def test_pick_for_shard_source_uses_global_pool(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_pick_fleet_with_empty_global_pool_returns_none(tmp_path, monkeypatch):
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(tmp_path / "missing.json"))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "missing.json"))
repo = _FakeRepo(deckies=[_decky(source="fleet", topology_id=None)])
assert await scheduler.pick(repo, now=datetime(2026, 4, 26, 12, 0, 0)) is None
@@ -191,7 +192,7 @@ async def test_topology_personas_isolated_from_global_pool(tmp_path, monkeypatch
"tone": "casual",
"mannerisms": [],
}]))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(pool_file))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool_file))
repo = _FakeRepo(
deckies=[_decky()],

View File

@@ -10,8 +10,8 @@ import pytest_asyncio
from decnet.bus.fake import FakeBus
from decnet.orchestrator.drivers import email as email_driver
from decnet.orchestrator.emailgen import worker as eg_worker
from decnet.orchestrator.emailgen.llm.impl.fake import FakeBackend
from decnet.orchestrator.emailgen.scheduler import EmailAction # noqa: F401
from decnet.realism.llm.impl.fake import FakeBackend
from decnet.web.db.models import Topology, TopologyDecky
from decnet.web.db.sqlite.repository import SQLiteRepository

View File

@@ -58,7 +58,7 @@ def test_in_work_hours_equal_start_end_means_always_on() -> None:
)
def test_malformed_window_fails_open(garbage: str) -> None:
# The fleet must not silence on a typo — same fail-open semantics
# as decnet.orchestrator.emailgen.personas.in_active_hours.
# as decnet.realism.personas.in_active_hours.
assert in_work_hours(garbage, _NOW) is True

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
import random
from decnet.orchestrator.emailgen.personas import EmailPersona
from decnet.orchestrator.emailgen.prompt import (
from decnet.realism.personas import EmailPersona
from decnet.realism.prompts.email import (
PromptInputs,
build,
select_mannerisms,

View File

@@ -5,34 +5,34 @@ import asyncio
import pytest
from decnet.orchestrator.emailgen.llm import LLMTimeout, get_llm
from decnet.orchestrator.emailgen.llm.impl.fake import FakeBackend
from decnet.orchestrator.emailgen.llm.impl.ollama import OllamaBackend
from decnet.realism.llm import LLMTimeout, get_llm
from decnet.realism.llm.impl.fake import FakeBackend
from decnet.realism.llm.impl.ollama import OllamaBackend
# ── factory dispatch ─────────────────────────────────────────────────────────
def test_factory_default_is_ollama(monkeypatch):
monkeypatch.delenv("DECNET_EMAILGEN_LLM", raising=False)
monkeypatch.delenv("DECNET_REALISM_LLM", raising=False)
backend = get_llm()
assert isinstance(backend, OllamaBackend)
def test_factory_selects_fake(monkeypatch):
monkeypatch.setenv("DECNET_EMAILGEN_LLM", "fake")
monkeypatch.setenv("DECNET_REALISM_LLM", "fake")
backend = get_llm()
assert isinstance(backend, FakeBackend)
def test_factory_unknown_raises(monkeypatch):
monkeypatch.setenv("DECNET_EMAILGEN_LLM", "vllm-someday")
monkeypatch.setenv("DECNET_REALISM_LLM", "vllm-someday")
with pytest.raises(ValueError, match="Unsupported"):
get_llm()
def test_factory_passes_model_through(monkeypatch):
monkeypatch.setenv("DECNET_EMAILGEN_LLM", "ollama")
monkeypatch.setenv("DECNET_REALISM_LLM", "ollama")
backend = get_llm(model="qwen2:7b")
assert backend.model == "qwen2:7b"

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from decnet.orchestrator.emailgen.personas import (
from decnet.realism.personas import (
EmailPersona,
in_active_hours,
parse_personas,

View File

@@ -5,7 +5,7 @@ import json
import pytest
from decnet.orchestrator.emailgen import global_pool
from decnet.realism import personas_pool as global_pool
@pytest.fixture(autouse=True)
@@ -35,7 +35,7 @@ _TWO = [
def test_load_returns_empty_when_file_missing(tmp_path, monkeypatch):
monkeypatch.setenv(
"DECNET_EMAILGEN_PERSONAS", str(tmp_path / "does-not-exist.json")
"DECNET_REALISM_PERSONAS", str(tmp_path / "does-not-exist.json")
)
assert global_pool.load() == []
@@ -43,7 +43,7 @@ def test_load_returns_empty_when_file_missing(tmp_path, monkeypatch):
def test_load_returns_parsed_personas(tmp_path, monkeypatch):
f = tmp_path / "personas.json"
f.write_text(json.dumps(_TWO))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(f))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
personas = global_pool.load()
assert len(personas) == 2
assert {p.email for p in personas} == {"john@corp.com", "sarah@corp.com"}
@@ -52,7 +52,7 @@ def test_load_returns_parsed_personas(tmp_path, monkeypatch):
def test_load_resolves_language_default(tmp_path, monkeypatch):
f = tmp_path / "personas.json"
f.write_text(json.dumps(_TWO))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(f))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
personas = global_pool.load(language_default="es")
assert all(p.language == "es" for p in personas)
@@ -60,14 +60,14 @@ def test_load_resolves_language_default(tmp_path, monkeypatch):
def test_load_invalid_json_returns_empty(tmp_path, monkeypatch):
f = tmp_path / "personas.json"
f.write_text("{not valid")
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(f))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
assert global_pool.load() == []
def test_load_caches_until_mtime_changes(tmp_path, monkeypatch):
f = tmp_path / "personas.json"
f.write_text(json.dumps(_TWO))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(f))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(f))
first = global_pool.load()
assert len(first) == 2
@@ -84,12 +84,12 @@ def test_load_caches_until_mtime_changes(tmp_path, monkeypatch):
def test_resolve_path_honours_env_override(tmp_path, monkeypatch):
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(tmp_path / "x.json"))
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(tmp_path / "x.json"))
assert global_pool.resolve_path() == tmp_path / "x.json"
def test_resolve_path_falls_back_to_user_path_when_system_missing(monkeypatch):
monkeypatch.delenv("DECNET_EMAILGEN_PERSONAS", raising=False)
monkeypatch.delenv("DECNET_REALISM_PERSONAS", raising=False)
# In a typical dev box /etc/decnet/ doesn't exist; the resolver
# should pick ~/.decnet/email_personas.json.
p = global_pool.resolve_path()