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.
- moodsona/__init__.py +75 -0
- moodsona/checks.py +113 -0
- moodsona/cli.py +80 -0
- moodsona/companion.py +84 -0
- moodsona/lang/__init__.py +67 -0
- moodsona/lang/base.py +77 -0
- moodsona/lang/english.py +521 -0
- moodsona/lang/noise_tables.py +86 -0
- moodsona/llm/__init__.py +26 -0
- moodsona/llm/anthropic.py +90 -0
- moodsona/llm/base.py +83 -0
- moodsona/llm/fake.py +124 -0
- moodsona/llm/openai.py +91 -0
- moodsona/messages.py +149 -0
- moodsona/parse.py +190 -0
- moodsona/personas/community/.gitkeep +0 -0
- moodsona/personas/examples/anxious-newcomer.yaml +42 -0
- moodsona/personas/examples/boundary-tester.yaml +57 -0
- moodsona/personas/examples/burned-out-shift-worker.yaml +54 -0
- moodsona/personas/examples/conflict-at-work.yaml +56 -0
- moodsona/personas/examples/grieving-after-loss.yaml +55 -0
- moodsona/personas/examples/lonely-night-owl.yaml +55 -0
- moodsona/personas/examples/skeptical-first-timer.yaml +57 -0
- moodsona/personas/schema.json +161 -0
- moodsona/personas.py +149 -0
- moodsona/prompts.py +193 -0
- moodsona/py.typed +0 -0
- moodsona/runner.py +229 -0
- moodsona/schema.py +177 -0
- moodsona/simulator.py +378 -0
- moodsona/state.py +292 -0
- moodsona/validation.py +212 -0
- moodsona/version.py +3 -0
- moodsona-0.1.0.dist-info/METADATA +215 -0
- moodsona-0.1.0.dist-info/RECORD +40 -0
- moodsona-0.1.0.dist-info/WHEEL +4 -0
- moodsona-0.1.0.dist-info/entry_points.txt +2 -0
- moodsona-0.1.0.dist-info/licenses/LICENSE +202 -0
- moodsona-0.1.0.dist-info/licenses/LICENSE-DATA.md +34 -0
- 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
|
+
...
|