feat(agents): tarot agent — daily three-card draw (situation/action/outcome) (#120)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
|
||||||
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
|
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
|
||||||
from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST
|
from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST
|
||||||
|
from .tarot import TarotAgent, MANIFEST as TAROT_MANIFEST
|
||||||
|
|
||||||
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||||
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
||||||
@@ -25,6 +26,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
|||||||
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
||||||
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
||||||
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
||||||
|
(TarotAgent(), TAROT_MANIFEST),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
||||||
|
|||||||
110
ml/agents/tarot.py
Normal file
110
ml/agents/tarot.py
Normal file
@@ -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)
|
||||||
@@ -13,6 +13,7 @@ from ml.agents.momentum import MomentumAgent
|
|||||||
from ml.agents.time_of_day import TimeOfDayAgent
|
from ml.agents.time_of_day import TimeOfDayAgent
|
||||||
from ml.agents.recent_patterns import RecentPatternsAgent
|
from ml.agents.recent_patterns import RecentPatternsAgent
|
||||||
from ml.agents.focus_area import FocusAreaAgent
|
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
|
from ml.agents.registry import get_agent, all_agents
|
||||||
|
|
||||||
_NOW = datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc) # Thursday 09:00 UTC
|
_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)
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestRegistry:
|
class TestRegistry:
|
||||||
def test_all_agents_present(self):
|
def test_all_agents_present(self):
|
||||||
agents = all_agents()
|
agents = all_agents()
|
||||||
ids = {a.agent_id for a in 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):
|
def test_get_agent(self):
|
||||||
a = get_agent("momentum")
|
a = get_agent("momentum")
|
||||||
|
|||||||
Reference in New Issue
Block a user