From be8c006a4dacb20c5ae6ae3fb645658ed4a29b11 Mon Sep 17 00:00:00 2001 From: alvis Date: Thu, 14 May 2026 10:52:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(agents):=20tarot=20agent=20=E2=80=94=20dai?= =?UTF-8?q?ly=20three-card=20draw=20(situation/action/outcome)=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draws 3 Major Arcana cards from a daily seed (user_id + date) so the reading is stable within a day and unique per user. Card meanings and action hints are precomputed in the agent; the orchestrator receives a structured prompt snippet and is instructed to weave the themes into a grounded, practical tip without explaining the cards. No inferred params, no external data — requires only data:core consent. TTL 6 h (refreshes at most twice daily). Co-Authored-By: Claude Sonnet 4.6 --- ml/agents/registry.py | 2 + ml/agents/tarot.py | 110 +++++++++++++++++++++++++++++++++ ml/agents/tests/test_agents.py | 49 ++++++++++++++- 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 ml/agents/tarot.py diff --git a/ml/agents/registry.py b/ml/agents/registry.py index 6635e3b..0263d62 100644 --- a/ml/agents/registry.py +++ b/ml/agents/registry.py @@ -17,6 +17,7 @@ from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST +from .tarot import TarotAgent, MANIFEST as TAROT_MANIFEST _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [ (OverdueTaskAgent(), OVERDUE_TASK_MANIFEST), @@ -25,6 +26,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [ (RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST), (FocusAreaAgent(), FOCUS_AREA_MANIFEST), (HealthVitalsAgent(), HEALTH_VITALS_MANIFEST), + (TarotAgent(), TAROT_MANIFEST), ] # Sanity check — agent_id and manifest.id must agree, otherwise the registry diff --git a/ml/agents/tarot.py b/ml/agents/tarot.py new file mode 100644 index 0000000..1b12b09 --- /dev/null +++ b/ml/agents/tarot.py @@ -0,0 +1,110 @@ +"""TAROT agent — three-card draw (situation / action / outcome). + +Draws cards deterministically from a daily seed so the reading stays +stable for the day (same cards whether the agent runs at 08:00 or 14:00). +Card meanings are precomputed here and passed as a structured snippet to +the orchestrator, which weaves them into a grounded, actionable tip. +""" +from __future__ import annotations + +import hashlib +from typing import ClassVar + +from .base import BaseAgent, AgentInput, AgentOutput +from .manifest import AgentManifest + +# --------------------------------------------------------------------------- +# Card definitions — Major Arcana only (22 cards, indices 0–21) +# Each entry: (name, upright_meaning, action_hint) +# --------------------------------------------------------------------------- +_CARDS: list[tuple[str, str, str]] = [ + ("The Fool", "new beginnings, spontaneity, a leap of faith", "start something without overthinking"), + ("The Magician", "skill, willpower, resourcefulness", "use what you already have"), + ("The High Priestess","intuition, inner knowing, patience", "listen to what you already sense is true"), + ("The Empress", "abundance, creativity, nurturing", "invest energy in something generative"), + ("The Emperor", "structure, authority, discipline", "set a boundary or impose order"), + ("The Hierophant", "tradition, guidance, shared values", "seek or offer mentorship"), + ("The Lovers", "alignment, choice, commitment", "make a decision you have been avoiding"), + ("The Chariot", "determination, focus, forward motion", "push through the resistance"), + ("Strength", "inner courage, patience, gentle persistence", "stay the course with compassion"), + ("The Hermit", "solitude, reflection, inner guidance", "step back and think before acting"), + ("Wheel of Fortune", "cycles, turning points, inevitable change", "acknowledge what is shifting around you"), + ("Justice", "fairness, truth, cause and effect", "audit a recent decision for its real consequences"), + ("The Hanged Man", "pause, surrender, new perspective", "release your grip on the outcome"), + ("Death", "endings, transformation, release", "let go of what no longer serves you"), + ("Temperance", "balance, moderation, patience", "blend two competing demands"), + ("The Devil", "attachment, habit, shadow patterns", "name a loop you are stuck in"), + ("The Tower", "sudden disruption, revelation, necessary collapse", "accept the thing that already broke"), + ("The Star", "hope, renewal, calm after the storm", "trust that recovery is already underway"), + ("The Moon", "uncertainty, illusion, the unconscious", "sit with ambiguity rather than forcing clarity"), + ("The Sun", "clarity, vitality, success", "act from your most energised self"), + ("Judgement", "reflection, reckoning, a call to rise", "respond to a long-deferred summons"), + ("The World", "completion, integration, a cycle closing", "acknowledge what you have finished"), +] + +_POSITIONS = ("situation", "action", "outcome") + + +def _daily_draw(user_id: str, date_str: str) -> list[int]: + """Return three distinct card indices seeded by (user_id, date).""" + seed = hashlib.sha256(f"{user_id}:{date_str}".encode()).digest() + indices: list[int] = [] + offset = 0 + while len(indices) < 3: + val = int.from_bytes(seed[offset:offset + 2], "big") % len(_CARDS) + if val not in indices: + indices.append(val) + offset = (offset + 2) % (len(seed) - 1) + return indices + + +MANIFEST = AgentManifest( + id="tarot", + version="1.0.0", + description="Daily three-card draw (situation/action/outcome) that frames the tip as a symbolic reflection.", + pref_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": { + "type": "boolean", + "default": True, + "description": "Set false to disable the tarot agent for this user.", + }, + }, + }, + context_schema=[], + required_consents=["data:core"], + output_contract={"type": "snippet", "format": "free_text"}, + ttl_sec=3_600 * 6, # stable for 6 h; refreshes mid-day at most twice + silenced_in_contexts=[], + inferred_params=[], +) + + +class TarotAgent(BaseAgent): + """Produces a three-card reading as a prompt snippet.""" + agent_id: ClassVar[str] = MANIFEST.id + ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec + version: ClassVar[str] = MANIFEST.version + + def compute(self, inp: AgentInput) -> AgentOutput: + date_str = inp.now.strftime("%Y-%m-%d") + indices = _daily_draw(inp.user_id, date_str) + + reading: list[dict] = [] + parts: list[str] = [f"Today's tarot reading ({date_str}):"] + for pos, idx in zip(_POSITIONS, indices): + name, meaning, hint = _CARDS[idx] + reading.append({"position": pos, "card": name, "meaning": meaning, "hint": hint}) + parts.append(f" {pos.capitalize()} — {name}: {meaning}. Hint: {hint}.") + + parts.append( + "Weave these symbolic themes lightly into the tip — " + "ground them in practical, specific action. " + "Do not explain the cards; let their meaning shape the advice." + ) + + prompt = "\n".join(parts) + snapshot = {"date": date_str, "reading": reading} + return self._make_output(inp, prompt, snapshot) diff --git a/ml/agents/tests/test_agents.py b/ml/agents/tests/test_agents.py index d4f5442..c8e7504 100644 --- a/ml/agents/tests/test_agents.py +++ b/ml/agents/tests/test_agents.py @@ -13,6 +13,7 @@ from ml.agents.momentum import MomentumAgent from ml.agents.time_of_day import TimeOfDayAgent from ml.agents.recent_patterns import RecentPatternsAgent from ml.agents.focus_area import FocusAreaAgent +from ml.agents.tarot import TarotAgent, _daily_draw, _CARDS, _POSITIONS from ml.agents.registry import get_agent, all_agents _NOW = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) # Thursday 09:00 UTC @@ -250,13 +251,59 @@ class TestFocusAreaAgent: assert all("label" in c and "task_count" in c and "tasks" in c for c in clusters) +# ── TarotAgent ──────────────────────────────────────────────────────────────── + +class TestTarotAgent: + agent = TarotAgent() + + def test_basic_output(self): + out = self.agent.compute(_inp()) + _check_output(out, self.agent) + assert "situation" in out.prompt_text.lower() + assert "action" in out.prompt_text.lower() + assert "outcome" in out.prompt_text.lower() + assert out.signals_snapshot["date"] == "2026-05-01" + assert len(out.signals_snapshot["reading"]) == 3 + + def test_three_distinct_cards(self): + out = self.agent.compute(_inp()) + cards = [r["card"] for r in out.signals_snapshot["reading"]] + assert len(set(cards)) == 3 + + def test_positions_labelled(self): + out = self.agent.compute(_inp()) + positions = [r["position"] for r in out.signals_snapshot["reading"]] + assert positions == list(_POSITIONS) + + def test_daily_stability(self): + out1 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 8, 0, 0, tzinfo=timezone.utc))) + out2 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 20, 0, 0, tzinfo=timezone.utc))) + assert out1.signals_snapshot["reading"] == out2.signals_snapshot["reading"] + + def test_different_days_different_draw(self): + out1 = self.agent.compute(_inp(now=datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc))) + out2 = self.agent.compute(_inp(now=datetime(2026, 5, 2, 9, 0, 0, tzinfo=timezone.utc))) + assert out1.signals_snapshot["reading"] != out2.signals_snapshot["reading"] + + def test_different_users_different_draw(self): + out1 = self.agent.compute(_inp(user_id="user-A")) + out2 = self.agent.compute(_inp(user_id="user-B")) + assert out1.signals_snapshot["reading"] != out2.signals_snapshot["reading"] + + def test_daily_draw_returns_valid_indices(self): + indices = _daily_draw("u1", "2026-05-01") + assert len(indices) == 3 + assert len(set(indices)) == 3 + assert all(0 <= i < len(_CARDS) for i in indices) + + # ── Registry ───────────────────────────────────────────────────────────────── class TestRegistry: def test_all_agents_present(self): agents = all_agents() ids = {a.agent_id for a in agents} - assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals"} + assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals", "tarot"} def test_get_agent(self): a = get_agent("momentum")