diff --git a/decnet/cli/emailgen.py b/decnet/cli/emailgen.py index 4d8036e0..7d4efa92 100644 --- a/decnet/cli/emailgen.py +++ b/decnet/cli/emailgen.py @@ -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") diff --git a/decnet/orchestrator/drivers/email.py b/decnet/orchestrator/drivers/email.py index 547fd44a..c44b82f6 100644 --- a/decnet/orchestrator/drivers/email.py +++ b/decnet/orchestrator/drivers/email.py @@ -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") diff --git a/decnet/orchestrator/emailgen/__init__.py b/decnet/orchestrator/emailgen/__init__.py index c8f90a5b..b52c4376 100644 --- a/decnet/orchestrator/emailgen/__init__.py +++ b/decnet/orchestrator/emailgen/__init__.py @@ -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. diff --git a/decnet/orchestrator/emailgen/llm/__init__.py b/decnet/orchestrator/emailgen/llm/__init__.py deleted file mode 100644 index 4ce31dcc..00000000 --- a/decnet/orchestrator/emailgen/llm/__init__.py +++ /dev/null @@ -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"] diff --git a/decnet/orchestrator/emailgen/llm/impl/__init__.py b/decnet/orchestrator/emailgen/llm/impl/__init__.py deleted file mode 100644 index 33d12197..00000000 --- a/decnet/orchestrator/emailgen/llm/impl/__init__.py +++ /dev/null @@ -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`. -""" diff --git a/decnet/orchestrator/emailgen/personas.py b/decnet/orchestrator/emailgen/personas.py deleted file mode 100644 index 4f909f3c..00000000 --- a/decnet/orchestrator/emailgen/personas.py +++ /dev/null @@ -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* (0–23) 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 diff --git a/decnet/orchestrator/emailgen/scheduler.py b/decnet/orchestrator/emailgen/scheduler.py index e0cfa276..a1f80214 100644 --- a/decnet/orchestrator/emailgen/scheduler.py +++ b/decnet/orchestrator/emailgen/scheduler.py @@ -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") diff --git a/decnet/realism/__init__.py b/decnet/realism/__init__.py index 61be3052..2939a025 100644 --- a/decnet/realism/__init__.py +++ b/decnet/realism/__init__.py @@ -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 diff --git a/decnet/realism/diurnal.py b/decnet/realism/diurnal.py index c81a3318..dc1a2080 100644 --- a/decnet/realism/diurnal.py +++ b/decnet/realism/diurnal.py @@ -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("-") diff --git a/decnet/realism/llm/__init__.py b/decnet/realism/llm/__init__.py index 887e3348..9b124e1a 100644 --- a/decnet/realism/llm/__init__.py +++ b/decnet/realism/llm/__init__.py @@ -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"] diff --git a/decnet/orchestrator/emailgen/llm/base.py b/decnet/realism/llm/base.py similarity index 71% rename from decnet/orchestrator/emailgen/llm/base.py rename to decnet/realism/llm/base.py index 73eb7ca5..70691f8d 100644 --- a/decnet/orchestrator/emailgen/llm/base.py +++ b/decnet/realism/llm/base.py @@ -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 diff --git a/decnet/orchestrator/emailgen/llm/factory.py b/decnet/realism/llm/factory.py similarity index 53% rename from decnet/orchestrator/emailgen/llm/factory.py rename to decnet/realism/llm/factory.py index 71bf4edb..6711d2b0 100644 --- a/decnet/orchestrator/emailgen/llm/factory.py +++ b/decnet/realism/llm/factory.py @@ -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" ) diff --git a/decnet/realism/llm/impl/__init__.py b/decnet/realism/llm/impl/__init__.py new file mode 100644 index 00000000..23492ab6 --- /dev/null +++ b/decnet/realism/llm/impl/__init__.py @@ -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`. +""" diff --git a/decnet/orchestrator/emailgen/llm/impl/fake.py b/decnet/realism/llm/impl/fake.py similarity index 70% rename from decnet/orchestrator/emailgen/llm/impl/fake.py rename to decnet/realism/llm/impl/fake.py index 4b249032..d59dba51 100644 --- a/decnet/orchestrator/emailgen/llm/impl/fake.py +++ b/decnet/realism/llm/impl/fake.py @@ -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 diff --git a/decnet/orchestrator/emailgen/llm/impl/ollama.py b/decnet/realism/llm/impl/ollama.py similarity index 83% rename from decnet/orchestrator/emailgen/llm/impl/ollama.py rename to decnet/realism/llm/impl/ollama.py index 85bdb9e5..5c1735bc 100644 --- a/decnet/orchestrator/emailgen/llm/impl/ollama.py +++ b/decnet/realism/llm/impl/ollama.py @@ -1,9 +1,6 @@ """Ollama subprocess backend. Shells out to ``ollama run `` 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): diff --git a/decnet/realism/personas.py b/decnet/realism/personas.py index 13fce94b..6916bb19 100644 --- a/decnet/realism/personas.py +++ b/decnet/realism/personas.py @@ -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* (0–23) 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 diff --git a/decnet/orchestrator/emailgen/global_pool.py b/decnet/realism/personas_pool.py similarity index 68% rename from decnet/orchestrator/emailgen/global_pool.py rename to decnet/realism/personas_pool.py index a5d6d4d9..9c7a29fe 100644 --- a/decnet/orchestrator/emailgen/global_pool.py +++ b/decnet/realism/personas_pool.py @@ -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 `` (or by editing +populate via ``decnet realism import-personas `` (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 diff --git a/decnet/realism/prompts/__init__.py b/decnet/realism/prompts/__init__.py index 301a05b4..b5015033 100644 --- a/decnet/realism/prompts/__init__.py +++ b/decnet/realism/prompts/__init__.py @@ -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 diff --git a/decnet/orchestrator/emailgen/prompt.py b/decnet/realism/prompts/email.py similarity index 91% rename from decnet/orchestrator/emailgen/prompt.py rename to decnet/realism/prompts/email.py index 0e678d5a..432fd670 100644 --- a/decnet/orchestrator/emailgen/prompt.py +++ b/decnet/realism/prompts/email.py @@ -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} diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 61a4a28a..208fc74d 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -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 diff --git a/decnet/web/router/emailgen/api_personas.py b/decnet/web/router/emailgen/api_personas.py index 10b95123..9e8c0e70 100644 --- a/decnet/web/router/emailgen/api_personas.py +++ b/decnet/web/router/emailgen/api_personas.py @@ -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", diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py index 4e01feb3..a251a064 100644 --- a/decnet/web/router/topology/__init__.py +++ b/decnet/web/router/topology/__init__.py @@ -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) diff --git a/decnet/web/router/topology/api_personas.py b/decnet/web/router/topology/api_personas.py new file mode 100644 index 00000000..e2f6ea13 --- /dev/null +++ b/decnet/web/router/topology/api_personas.py @@ -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": [, ...]}``. + + 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, + } diff --git a/tests/api/emailgen/test_personas_api.py b/tests/api/emailgen/test_personas_api.py index 16ea9984..298fc250 100644 --- a/tests/api/emailgen/test_personas_api.py +++ b/tests/api/emailgen/test_personas_api.py @@ -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}, diff --git a/tests/cli/test_emailgen_import_personas.py b/tests/cli/test_emailgen_import_personas.py index 326493a6..ca59ead1 100644 --- a/tests/cli/test_emailgen_import_personas.py +++ b/tests/cli/test_emailgen_import_personas.py @@ -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)] diff --git a/tests/orchestrator/emailgen/test_driver.py b/tests/orchestrator/emailgen/test_driver.py index 7737f1ac..b1cc105f 100644 --- a/tests/orchestrator/emailgen/test_driver.py +++ b/tests/orchestrator/emailgen/test_driver.py @@ -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: diff --git a/tests/orchestrator/emailgen/test_events.py b/tests/orchestrator/emailgen/test_events.py index 960000e4..1e205081 100644 --- a/tests/orchestrator/emailgen/test_events.py +++ b/tests/orchestrator/emailgen/test_events.py @@ -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 diff --git a/tests/orchestrator/emailgen/test_scheduler.py b/tests/orchestrator/emailgen/test_scheduler.py index f3010d40..a0ee4bf8 100644 --- a/tests/orchestrator/emailgen/test_scheduler.py +++ b/tests/orchestrator/emailgen/test_scheduler.py @@ -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()], diff --git a/tests/orchestrator/emailgen/test_worker_integration.py b/tests/orchestrator/emailgen/test_worker_integration.py index eb03f003..bf84a81c 100644 --- a/tests/orchestrator/emailgen/test_worker_integration.py +++ b/tests/orchestrator/emailgen/test_worker_integration.py @@ -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 diff --git a/tests/realism/test_diurnal.py b/tests/realism/test_diurnal.py index 77c4bfc7..b5cdcb4f 100644 --- a/tests/realism/test_diurnal.py +++ b/tests/realism/test_diurnal.py @@ -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 diff --git a/tests/orchestrator/emailgen/test_prompt.py b/tests/realism/test_email_prompt.py similarity index 97% rename from tests/orchestrator/emailgen/test_prompt.py rename to tests/realism/test_email_prompt.py index 723b76d6..fcccbae5 100644 --- a/tests/orchestrator/emailgen/test_prompt.py +++ b/tests/realism/test_email_prompt.py @@ -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, diff --git a/tests/orchestrator/emailgen/test_llm.py b/tests/realism/test_llm.py similarity index 90% rename from tests/orchestrator/emailgen/test_llm.py rename to tests/realism/test_llm.py index 6f600b50..4a91d8fb 100644 --- a/tests/orchestrator/emailgen/test_llm.py +++ b/tests/realism/test_llm.py @@ -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" diff --git a/tests/orchestrator/emailgen/test_personas.py b/tests/realism/test_personas.py similarity index 98% rename from tests/orchestrator/emailgen/test_personas.py rename to tests/realism/test_personas.py index a4ba80ba..089117b0 100644 --- a/tests/orchestrator/emailgen/test_personas.py +++ b/tests/realism/test_personas.py @@ -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, diff --git a/tests/orchestrator/emailgen/test_global_pool.py b/tests/realism/test_personas_pool.py similarity index 83% rename from tests/orchestrator/emailgen/test_global_pool.py rename to tests/realism/test_personas_pool.py index 0d0cdb9c..6e3380ad 100644 --- a/tests/orchestrator/emailgen/test_global_pool.py +++ b/tests/realism/test_personas_pool.py @@ -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()