moodsona 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. moodsona/__init__.py +75 -0
  2. moodsona/checks.py +113 -0
  3. moodsona/cli.py +80 -0
  4. moodsona/companion.py +84 -0
  5. moodsona/lang/__init__.py +67 -0
  6. moodsona/lang/base.py +77 -0
  7. moodsona/lang/english.py +521 -0
  8. moodsona/lang/noise_tables.py +86 -0
  9. moodsona/llm/__init__.py +26 -0
  10. moodsona/llm/anthropic.py +90 -0
  11. moodsona/llm/base.py +83 -0
  12. moodsona/llm/fake.py +124 -0
  13. moodsona/llm/openai.py +91 -0
  14. moodsona/messages.py +149 -0
  15. moodsona/parse.py +190 -0
  16. moodsona/personas/community/.gitkeep +0 -0
  17. moodsona/personas/examples/anxious-newcomer.yaml +42 -0
  18. moodsona/personas/examples/boundary-tester.yaml +57 -0
  19. moodsona/personas/examples/burned-out-shift-worker.yaml +54 -0
  20. moodsona/personas/examples/conflict-at-work.yaml +56 -0
  21. moodsona/personas/examples/grieving-after-loss.yaml +55 -0
  22. moodsona/personas/examples/lonely-night-owl.yaml +55 -0
  23. moodsona/personas/examples/skeptical-first-timer.yaml +57 -0
  24. moodsona/personas/schema.json +161 -0
  25. moodsona/personas.py +149 -0
  26. moodsona/prompts.py +193 -0
  27. moodsona/py.typed +0 -0
  28. moodsona/runner.py +229 -0
  29. moodsona/schema.py +177 -0
  30. moodsona/simulator.py +378 -0
  31. moodsona/state.py +292 -0
  32. moodsona/validation.py +212 -0
  33. moodsona/version.py +3 -0
  34. moodsona-0.1.0.dist-info/METADATA +215 -0
  35. moodsona-0.1.0.dist-info/RECORD +40 -0
  36. moodsona-0.1.0.dist-info/WHEEL +4 -0
  37. moodsona-0.1.0.dist-info/entry_points.txt +2 -0
  38. moodsona-0.1.0.dist-info/licenses/LICENSE +202 -0
  39. moodsona-0.1.0.dist-info/licenses/LICENSE-DATA.md +34 -0
  40. moodsona-0.1.0.dist-info/licenses/NOTICE +11 -0
moodsona/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """moodsona: an emotional user simulator for testing AI companion chatbots.
2
+
3
+ Quick start (offline)::
4
+
5
+ from moodsona import EchoCompanion, FakeProvider, load_bundled_persona, run
6
+
7
+ persona = load_bundled_persona("anxious-newcomer")
8
+ transcript = run(persona, FakeProvider(), EchoCompanion(), days=2, turns_per_day=3)
9
+ for point in transcript.trajectory():
10
+ print(point)
11
+
12
+ Swap ``FakeProvider()`` for ``AnthropicProvider()`` or ``OpenAIProvider()``
13
+ (from ``moodsona.llm.anthropic`` / ``moodsona.llm.openai``) and ``EchoCompanion``
14
+ for your own chatbot wrapped in the :class:`Companion` protocol.
15
+ """
16
+
17
+ from moodsona.companion import Companion, EchoCompanion, ScriptedCompanion, ScriptExhaustedError
18
+ from moodsona.llm import ChatMessage, FakeProvider, LLMError, LLMProvider, LLMResponse
19
+ from moodsona.parse import HiddenBlock, ParseError
20
+ from moodsona.personas import (
21
+ PersonaError,
22
+ list_bundled_personas,
23
+ load_bundled_persona,
24
+ load_persona,
25
+ persona_from_mapping,
26
+ )
27
+ from moodsona.runner import TrajectoryPoint, Transcript, TurnRecord, run
28
+ from moodsona.simulator import SafetyHook, Simulator, SimulatorConfig, SimulatorTurn
29
+ from moodsona.state import (
30
+ DayNote,
31
+ Intent,
32
+ Mood,
33
+ PersonaSeed,
34
+ PersonaState,
35
+ Pronouns,
36
+ ReactionRule,
37
+ Stance,
38
+ )
39
+ from moodsona.version import __version__
40
+
41
+ __all__ = [
42
+ "ChatMessage",
43
+ "Companion",
44
+ "DayNote",
45
+ "EchoCompanion",
46
+ "FakeProvider",
47
+ "HiddenBlock",
48
+ "Intent",
49
+ "LLMError",
50
+ "LLMProvider",
51
+ "LLMResponse",
52
+ "Mood",
53
+ "ParseError",
54
+ "PersonaError",
55
+ "PersonaSeed",
56
+ "PersonaState",
57
+ "Pronouns",
58
+ "ReactionRule",
59
+ "SafetyHook",
60
+ "ScriptExhaustedError",
61
+ "ScriptedCompanion",
62
+ "Simulator",
63
+ "SimulatorConfig",
64
+ "SimulatorTurn",
65
+ "Stance",
66
+ "TrajectoryPoint",
67
+ "Transcript",
68
+ "TurnRecord",
69
+ "__version__",
70
+ "list_bundled_personas",
71
+ "load_bundled_persona",
72
+ "load_persona",
73
+ "persona_from_mapping",
74
+ "run",
75
+ ]
moodsona/checks.py ADDED
@@ -0,0 +1,113 @@
1
+ """Checks on a simulated user's message before it reaches the companion.
2
+
3
+ A simulation is only evidence while the simulator keeps writing as the
4
+ fictional user. Models sometimes break role instead: they state their own
5
+ identity, refuse, or talk about the task. Those lines must never enter the
6
+ transcript as if a person had sent them, so the simulator retries them.
7
+
8
+ The patterns are deliberately narrow. A simulated user may well say "I can't
9
+ sleep" or ask "are you ChatGPT?"; only the simulator claiming an assistant
10
+ identity for itself, refusing the task, or leaking the scaffold counts.
11
+ Language-specific checks (wrong language, reflexive agreement, asking back)
12
+ live in the language profile, not here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterable
19
+
20
+ #: Straight or typographic apostrophe.
21
+ _APOS = "['\u2019]"
22
+ _VENDORS = (
23
+ r"claude|anthropic|openai|chatgpt|gpt-?\d[\w.-]*|gemini|grok|xai|llama|deepseek|qwen|mistral"
24
+ )
25
+
26
+ _IDENTITY = re.compile(
27
+ r"\bas\s+an?\s+(?:ai|artificial\s+intelligence|assistant|language\s+model|chatbot)\b"
28
+ rf"|\bi\s*(?:am|{_APOS}m)\s+(?:just\s+|only\s+)?(?:an?\s+)?"
29
+ r"(?:ai\b|ai\s+(?:model|assistant)|artificial\s+intelligence|(?:large\s+)?language\s+model"
30
+ r"|virtual\s+assistant|chatbot)"
31
+ # "I'm Claude." / "I am Gemini, an AI ..." but not "I'm Gemini so I overthink"
32
+ # (astrology) or "I'm Grok-level tired": the name must end the clause or be
33
+ # followed by a word that makes it an assistant.
34
+ rf"|\bi\s*(?:am|{_APOS}m)\s+(?:{_VENDORS})"
35
+ r"(?=\s*(?:[.!?,;:]|$)|\s+(?:ai|assistant|(?:language\s+)?model|chatbot|bot)\b)"
36
+ rf"|\b(?:made|created|trained|developed|built)\s+by\s+(?:{_VENDORS})\b",
37
+ re.IGNORECASE | re.MULTILINE,
38
+ )
39
+
40
+ _REFUSAL = re.compile(
41
+ rf"\bi\s+(?:can(?:not|{_APOS}t| not)|won{_APOS}t|will\s+not|am\s+not\s+able\s+to)\s+"
42
+ r"(?:help\s+with\s+(?:that|this)|assist\s+with|continue\s+(?:this|with\s+this)"
43
+ r"|comply|role-?play|pretend\s+to\s+be|play\s+(?:this|that)\s+(?:role|character)"
44
+ r"|write\s+(?:this|that)\s+(?:message|line))"
45
+ r"|\bi\s+must\s+decline\b"
46
+ rf"|\bi(?:{_APOS}m|\s+am)\s+sorry,?\s+but\s+i\s+(?:can|won|will|am)",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+ # Scaffold vocabulary: payload keys and talk about the prompt or the fiction.
51
+ _META = re.compile(
52
+ r"\b(?:inner_thought|hidden_goal|reaction_rules|visible_conversation|memory_log"
53
+ r"|blocked_openers|rules_triggered|turn_in_day)\b"
54
+ r"|<\s*/?\s*(?:state|message)\s*>"
55
+ r"|\bsystem\s+prompt\b|\bthe\s+(?:prompt|persona|hidden\s+state)\b"
56
+ r"|\bthis\s+(?:role-?play|simulation|fiction)\b|\b(?:fictional|simulated)\s+(?:character|user)\b"
57
+ r"|\bin\s+character\b",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+ ROLE_BREAK_KINDS = ("empty_output", "identity_claim", "task_refusal", "meta_content")
62
+
63
+
64
+ def find_role_break(text: str) -> str | None:
65
+ """Return the kind of the first role break in ``text``, or None.
66
+
67
+ Args:
68
+ text: A simulated user's visible message (or notes).
69
+
70
+ Returns:
71
+ One of ``ROLE_BREAK_KINDS`` or None when the text is fine.
72
+ """
73
+ if not text.strip():
74
+ return "empty_output"
75
+ if _IDENTITY.search(text):
76
+ return "identity_claim"
77
+ if _REFUSAL.search(text):
78
+ return "task_refusal"
79
+ if _META.search(text):
80
+ return "meta_content"
81
+ return None
82
+
83
+
84
+ _WORD = re.compile(r"\w+", re.UNICODE)
85
+
86
+
87
+ def words(text: str) -> set[str]:
88
+ """The set of lower-case words in ``text``."""
89
+ return {w.lower() for w in _WORD.findall(text)}
90
+
91
+
92
+ def repeats_earlier(
93
+ text: str, earlier: Iterable[str], *, threshold: float = 0.7, min_words: int = 3
94
+ ) -> bool:
95
+ """Tell whether ``text`` nearly repeats one of the ``earlier`` lines.
96
+
97
+ Similarity is the Jaccard index of the two word sets; lines shorter than
98
+ ``min_words`` words are never counted as repeats ("ok", "yeah sure").
99
+ """
100
+ mine = words(text)
101
+ if len(mine) < min_words:
102
+ return False
103
+ for line in earlier:
104
+ other = words(line)
105
+ if other and len(mine & other) / len(mine | other) >= threshold:
106
+ return True
107
+ return False
108
+
109
+
110
+ def opener(text: str, *, max_words: int = 3) -> str | None:
111
+ """The first few words of a message, lower-cased, for the anti-mirroring list."""
112
+ found = _WORD.findall(text.lower())[:max_words]
113
+ return " ".join(found) if found else None
moodsona/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command line interface: ``moodsona validate`` and ``moodsona smoke``.
2
+
3
+ Exit codes:
4
+ 0: success.
5
+ 1: ``validate`` found problems, or ``smoke`` could not load or run the persona.
6
+ 2: usage error (unknown command or bad arguments, printed by argparse; or
7
+ ``validate`` got a missing path or found no persona file to check).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from collections.abc import Sequence
15
+
16
+ from moodsona.companion import EchoCompanion
17
+ from moodsona.llm.base import LLMError
18
+ from moodsona.llm.fake import FakeProvider
19
+ from moodsona.personas import PersonaError, load_persona
20
+ from moodsona.runner import run
21
+ from moodsona.validation import main as validate_main
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="moodsona",
27
+ description="Emotional user simulator for testing AI companion chatbots.",
28
+ epilog="Exit codes: 0 success, 1 problems found or run failed, 2 usage error.",
29
+ )
30
+ commands = parser.add_subparsers(dest="command", required=True)
31
+ check = commands.add_parser(
32
+ "validate", help="check persona files (schema, ids, no contact data)"
33
+ )
34
+ check.add_argument(
35
+ "paths", nargs="*", help="files or directories (default: the bundled personas)"
36
+ )
37
+ smoke = commands.add_parser(
38
+ "smoke", help="run a persona offline (fake model, echo companion) and print its mood"
39
+ )
40
+ smoke.add_argument("persona", help="path to a persona YAML file")
41
+ smoke.add_argument("--days", type=int, default=1, help="simulated days (default: 1)")
42
+ smoke.add_argument("--turns", type=int, default=3, help="turns per day (default: 3)")
43
+ smoke.add_argument("--seed", type=int, default=0, help="random seed (default: 0)")
44
+ return parser
45
+
46
+
47
+ def _smoke(args: argparse.Namespace) -> int:
48
+ try:
49
+ seed = load_persona(args.persona)
50
+ except (PersonaError, FileNotFoundError) as exc: # FileNotFoundError: broken install
51
+ sys.stderr.write(f"{exc}\n")
52
+ return 1
53
+ if args.days < 1 or args.turns < 1:
54
+ sys.stderr.write("--days and --turns must be at least 1\n")
55
+ return 1
56
+ try:
57
+ transcript = run(seed, FakeProvider(), EchoCompanion(), args.days, args.turns, args.seed)
58
+ except (LookupError, LLMError) as exc:
59
+ sys.stderr.write(f"{exc}\n")
60
+ return 1
61
+ out = sys.stdout
62
+ out.write(f"persona {seed.id}: {len(transcript.turns)} turn(s), offline fake model\n")
63
+ out.write(f"{'day':>3} {'turn':>4} {'emotion':>7} {'energy':>6} stance\n")
64
+ for point in transcript.trajectory():
65
+ out.write(
66
+ f"{point.day:>3} {point.turn:>4} {point.emotion:>7} {point.energy:>6} {point.stance}\n"
67
+ )
68
+ return 0
69
+
70
+
71
+ def main(argv: Sequence[str] | None = None) -> int:
72
+ """Run the CLI and return its exit code."""
73
+ args = _parser().parse_args(argv)
74
+ if args.command == "validate":
75
+ return validate_main(args.paths)
76
+ return _smoke(args)
77
+
78
+
79
+ if __name__ == "__main__": # pragma: no cover
80
+ sys.exit(main())
moodsona/companion.py ADDED
@@ -0,0 +1,84 @@
1
+ """The companion under test, and two test doubles.
2
+
3
+ Wrap your chatbot in a class with one method, ``reply(history) -> str``. The
4
+ history holds every visible message so far, across all simulated days:
5
+ ``user`` messages come from the simulated person, ``assistant`` messages are
6
+ your companion's earlier replies. Nothing hidden (inner thoughts, state, notes)
7
+ is ever in it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Sequence
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from moodsona.llm.base import ChatMessage
16
+
17
+
18
+ @runtime_checkable
19
+ class Companion(Protocol):
20
+ """The chatbot being tested."""
21
+
22
+ def reply(self, history: Sequence[ChatMessage]) -> str:
23
+ """Return the companion's next message.
24
+
25
+ Args:
26
+ history: All visible messages so far; the last one is the
27
+ simulated user's newest message.
28
+
29
+ Returns:
30
+ The companion's reply text.
31
+ """
32
+ ...
33
+
34
+
35
+ class EchoCompanion:
36
+ """Repeats the user's last message, with an optional prefix. For smoke tests.
37
+
38
+ Args:
39
+ prefix: Text put before the echoed message.
40
+ """
41
+
42
+ def __init__(self, prefix: str = "You said: ") -> None:
43
+ self.prefix = prefix
44
+
45
+ def reply(self, history: Sequence[ChatMessage]) -> str:
46
+ """Echo the newest user message (or an empty echo when there is none)."""
47
+ last = next((m.content for m in reversed(history) if m.role == "user"), "")
48
+ return f"{self.prefix}{last}"
49
+
50
+
51
+ class ScriptExhaustedError(RuntimeError):
52
+ """A :class:`ScriptedCompanion` ran out of replies."""
53
+
54
+
55
+ class ScriptedCompanion:
56
+ """Returns fixed replies in order and records every history it was given.
57
+
58
+ Args:
59
+ replies: The replies to return.
60
+ cycle: Start again from the first reply when the script runs out,
61
+ instead of raising :class:`ScriptExhaustedError`.
62
+
63
+ Attributes:
64
+ histories: The history passed on each call, in order.
65
+ """
66
+
67
+ def __init__(self, replies: Iterable[str], *, cycle: bool = False) -> None:
68
+ self.replies = tuple(replies)
69
+ if not self.replies:
70
+ raise ValueError("ScriptedCompanion needs at least one reply")
71
+ self.cycle = cycle
72
+ self.histories: list[tuple[ChatMessage, ...]] = []
73
+
74
+ def reply(self, history: Sequence[ChatMessage]) -> str:
75
+ """Return the next scripted reply.
76
+
77
+ Raises:
78
+ ScriptExhaustedError: No replies left and ``cycle`` is off.
79
+ """
80
+ index = len(self.histories)
81
+ self.histories.append(tuple(history))
82
+ if index >= len(self.replies) and not self.cycle:
83
+ raise ScriptExhaustedError(f"no scripted reply left (call {index + 1})")
84
+ return self.replies[index % len(self.replies)]
@@ -0,0 +1,67 @@
1
+ """Language profiles: everything in moodsona that depends on the language.
2
+
3
+ Use :func:`get_profile` to look a profile up by its BCP-47 tag::
4
+
5
+ from moodsona.lang import get_profile
6
+
7
+ english = get_profile("en")
8
+ english.is_ask_back("fine, you?") # True
9
+
10
+ v0.1 ships English only. A new language is one module with a class that
11
+ satisfies :class:`LanguageProfile`, plus one entry in ``_REGISTRY``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from types import MappingProxyType
18
+ from typing import Final
19
+
20
+ from moodsona.lang.base import LanguageProfile
21
+ from moodsona.lang.english import EnglishProfile, NoiseRates
22
+
23
+ __all__ = [
24
+ "EnglishProfile",
25
+ "LanguageProfile",
26
+ "NoiseRates",
27
+ "available_languages",
28
+ "get_profile",
29
+ ]
30
+
31
+ _REGISTRY: Final[MappingProxyType[str, Callable[[], LanguageProfile]]] = MappingProxyType(
32
+ {"en": EnglishProfile}
33
+ )
34
+
35
+
36
+ def available_languages() -> tuple[str, ...]:
37
+ """Return the language tags that have a profile, sorted.
38
+
39
+ Returns:
40
+ The registered BCP-47 primary language tags, for example ``("en",)``.
41
+ """
42
+ return tuple(sorted(_REGISTRY))
43
+
44
+
45
+ def get_profile(code: str) -> LanguageProfile:
46
+ """Return a fresh language profile with default settings.
47
+
48
+ The tag is matched case-insensitively and ``_`` is accepted for ``-``. A
49
+ regional tag falls back to its language: ``"en-US"`` and ``"en_GB"`` give
50
+ the English profile.
51
+
52
+ Args:
53
+ code: BCP-47 language tag, for example ``"en"`` or ``"en-US"``.
54
+
55
+ Returns:
56
+ A new profile instance for that language.
57
+
58
+ Raises:
59
+ LookupError: If no profile covers ``code``; the message lists the
60
+ available tags.
61
+ """
62
+ normalised = code.strip().replace("_", "-").lower()
63
+ factory = _REGISTRY.get(normalised) or _REGISTRY.get(normalised.split("-", 1)[0])
64
+ if factory is None:
65
+ available = ", ".join(available_languages())
66
+ raise LookupError(f"No language profile for {code!r}. Available: {available}.")
67
+ return factory()
moodsona/lang/base.py ADDED
@@ -0,0 +1,77 @@
1
+ """The language profile protocol.
2
+
3
+ A language profile holds everything in moodsona that depends on the language a
4
+ simulated user writes in: spotting a reply in the wrong language, spotting a
5
+ bare agreement or a reciprocal question, and adding the typing noise of a
6
+ person on a phone keyboard. The simulator core talks only to this protocol, so
7
+ adding a language means adding one profile and registering it in
8
+ :mod:`moodsona.lang`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ from typing import Protocol, runtime_checkable
15
+
16
+ __all__ = ["LanguageProfile"]
17
+
18
+
19
+ @runtime_checkable
20
+ class LanguageProfile(Protocol):
21
+ """Language-specific heuristics used by the simulator.
22
+
23
+ Attributes:
24
+ code: BCP-47 language tag the profile covers, for example ``"en"``.
25
+ """
26
+
27
+ code: str
28
+
29
+ def is_wrong_language(self, text: str) -> bool:
30
+ """Tell whether ``text`` is written in a different language.
31
+
32
+ Args:
33
+ text: A message written by the simulated user.
34
+
35
+ Returns:
36
+ True when the message is clearly not in this profile's language.
37
+ Text without letters (emoji, numbers, punctuation, empty) is never
38
+ the wrong language.
39
+ """
40
+ ...
41
+
42
+ def is_agreement(self, text: str) -> bool:
43
+ """Tell whether ``text`` is only a short affirmative reply.
44
+
45
+ Args:
46
+ text: A message written by the simulated user.
47
+
48
+ Returns:
49
+ True for replies such as "yeah" or "makes sense" that carry nothing
50
+ but agreement; False for negations and for longer messages.
51
+ """
52
+ ...
53
+
54
+ def is_ask_back(self, text: str) -> bool:
55
+ """Tell whether ``text`` ends with a reciprocal question.
56
+
57
+ Args:
58
+ text: A message written by the simulated user.
59
+
60
+ Returns:
61
+ True when the message ends by turning the question back to the
62
+ other side ("and you?", "what about you?").
63
+ """
64
+ ...
65
+
66
+ def add_typing_noise(self, text: str, rng: random.Random, level: float) -> str:
67
+ """Return ``text`` with messenger-style typing noise.
68
+
69
+ Args:
70
+ text: A clean message.
71
+ rng: Source of randomness; the same seed gives the same output.
72
+ level: Noise intensity in ``[0, 1]``; ``0`` returns ``text`` as is.
73
+
74
+ Returns:
75
+ The noisy message.
76
+ """
77
+ ...