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

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