feat(agents): stars agent — astrological transits via pyswisseph (#121)
Computes natal chart (Sun/Moon/Mercury/Venus/Mars/Jupiter/Saturn) from birth_date and finds active transits (conjunction/sextile/square/trine/ opposition) between today's sky and the user's natal positions. Top 3 most-exact transits are passed to the orchestrator as interpretive themes to colour the tip — grounded and actionable, not predictive. Birth date sourced from agent_prefs (populated by a connected Google data source); requires data:google-health consent. Agent self-silences when birth_date is absent. pyswisseph added to ml/serving/requirements.txt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MA
|
|||||||
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
|
from .tarot import TarotAgent, MANIFEST as TAROT_MANIFEST
|
||||||
|
from .stars import StarsAgent, MANIFEST as STARS_MANIFEST
|
||||||
|
|
||||||
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||||
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
||||||
@@ -27,6 +28,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
|||||||
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
||||||
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
||||||
(TarotAgent(), TAROT_MANIFEST),
|
(TarotAgent(), TAROT_MANIFEST),
|
||||||
|
(StarsAgent(), STARS_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
|
||||||
|
|||||||
233
ml/agents/stars.py
Normal file
233
ml/agents/stars.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Stars agent — astrological transit predictions via pyswisseph.
|
||||||
|
|
||||||
|
Requires birth_date in agent_prefs (ISO 8601 date string, e.g. '1990-06-15').
|
||||||
|
Populated from a connected data source (Google profile / Google Health).
|
||||||
|
If birth_date is absent the agent returns a no-data snippet and the
|
||||||
|
eligibility filter will silence it once the consent / pref check catches up.
|
||||||
|
|
||||||
|
Computes today's Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn positions
|
||||||
|
and finds notable transits (conjunctions, oppositions, squares, trines, sextiles)
|
||||||
|
between today's sky and the user's natal chart. Passes a concise prediction
|
||||||
|
+ interpretation to the orchestrator.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from .base import BaseAgent, AgentInput, AgentOutput
|
||||||
|
from .manifest import AgentManifest, InferredParam
|
||||||
|
|
||||||
|
try:
|
||||||
|
import swisseph as swe # type: ignore
|
||||||
|
_SWE_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover — present in container, absent in dev
|
||||||
|
_SWE_AVAILABLE = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Planet catalogue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PLANETS: list[tuple[int, str]] = []
|
||||||
|
if _SWE_AVAILABLE:
|
||||||
|
_PLANETS = [
|
||||||
|
(swe.SUN, "Sun"),
|
||||||
|
(swe.MOON, "Moon"),
|
||||||
|
(swe.MERCURY, "Mercury"),
|
||||||
|
(swe.VENUS, "Venus"),
|
||||||
|
(swe.MARS, "Mars"),
|
||||||
|
(swe.JUPITER, "Jupiter"),
|
||||||
|
(swe.SATURN, "Saturn"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Aspect definitions: (angle, orb, name, nature)
|
||||||
|
_ASPECTS: list[tuple[float, float, str, str]] = [
|
||||||
|
(0.0, 8.0, "conjunction", "intensifying"),
|
||||||
|
(60.0, 6.0, "sextile", "harmonious"),
|
||||||
|
(90.0, 7.0, "square", "challenging"),
|
||||||
|
(120.0, 8.0, "trine", "flowing"),
|
||||||
|
(180.0, 8.0, "opposition", "tension"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ZODIAC = [
|
||||||
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Interpretive keywords per planet for transit readings
|
||||||
|
_PLANET_THEMES: dict[str, str] = {
|
||||||
|
"Sun": "identity, vitality, core purpose",
|
||||||
|
"Moon": "emotions, intuition, comfort needs",
|
||||||
|
"Mercury": "communication, thinking, decisions",
|
||||||
|
"Venus": "relationships, values, pleasure",
|
||||||
|
"Mars": "energy, drive, conflict",
|
||||||
|
"Jupiter": "growth, opportunity, expansion",
|
||||||
|
"Saturn": "discipline, responsibility, long-term structure",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zodiac_sign(lon: float) -> str:
|
||||||
|
return _ZODIAC[int(lon / 30) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def _jd_from_date(d: date) -> float:
|
||||||
|
"""Julian Day Number for noon UTC on the given date."""
|
||||||
|
assert _SWE_AVAILABLE
|
||||||
|
return swe.julday(d.year, d.month, d.day, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _planet_positions(jd: float) -> dict[str, float]:
|
||||||
|
assert _SWE_AVAILABLE
|
||||||
|
positions: dict[str, float] = {}
|
||||||
|
for pid, name in _PLANETS:
|
||||||
|
result, _ = swe.calc_ut(jd, pid)
|
||||||
|
positions[name] = result[0] # ecliptic longitude
|
||||||
|
return positions
|
||||||
|
|
||||||
|
|
||||||
|
def _angular_diff(a: float, b: float) -> float:
|
||||||
|
"""Smallest angle between two ecliptic longitudes (0–180)."""
|
||||||
|
diff = abs(a - b) % 360
|
||||||
|
return diff if diff <= 180 else 360 - diff
|
||||||
|
|
||||||
|
|
||||||
|
def _find_transits(natal: dict[str, float], today: dict[str, float]) -> list[dict]:
|
||||||
|
"""Return list of active transits between today's sky and natal chart."""
|
||||||
|
transits: list[dict] = []
|
||||||
|
for t_name, t_lon in today.items():
|
||||||
|
for n_name, n_lon in natal.items():
|
||||||
|
diff = _angular_diff(t_lon, n_lon)
|
||||||
|
for angle, orb, aspect_name, nature in _ASPECTS:
|
||||||
|
if abs(diff - angle) <= orb:
|
||||||
|
transits.append({
|
||||||
|
"transit_planet": t_name,
|
||||||
|
"natal_planet": n_name,
|
||||||
|
"aspect": aspect_name,
|
||||||
|
"nature": nature,
|
||||||
|
"orb": round(abs(diff - angle), 2),
|
||||||
|
})
|
||||||
|
# Sort by tightness of orb
|
||||||
|
transits.sort(key=lambda x: x["orb"])
|
||||||
|
return transits
|
||||||
|
|
||||||
|
|
||||||
|
def _format_transit(t: dict) -> str:
|
||||||
|
tp, np, asp, nat = t["transit_planet"], t["natal_planet"], t["aspect"], t["nature"]
|
||||||
|
tp_theme = _PLANET_THEMES.get(tp, "")
|
||||||
|
np_theme = _PLANET_THEMES.get(np, "")
|
||||||
|
return (
|
||||||
|
f"Transiting {tp} ({tp_theme}) {asp} natal {np} ({np_theme}) "
|
||||||
|
f"— a {nat} influence"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MANIFEST = AgentManifest(
|
||||||
|
id="stars",
|
||||||
|
version="1.0.0",
|
||||||
|
description="Astrological transit predictions based on the user's birth date and today's planetary positions.",
|
||||||
|
pref_schema={
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"birth_date": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": r"^\d{4}-\d{2}-\d{2}$",
|
||||||
|
"description": "ISO 8601 birth date (YYYY-MM-DD). Populated from connected data source.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context_schema=["profile.birth_date"],
|
||||||
|
# Requires a connected Google source that supplies birth date.
|
||||||
|
# data:google-health is the current carrier; when Google profile is a
|
||||||
|
# separate consent key, add it here.
|
||||||
|
required_consents=["data:core", "data:google-health"],
|
||||||
|
output_contract={"type": "snippet", "format": "free_text"},
|
||||||
|
ttl_sec=3_600 * 6, # planetary positions change slowly — 6 h is fine
|
||||||
|
silenced_in_contexts=[],
|
||||||
|
inferred_params=[
|
||||||
|
InferredParam(
|
||||||
|
key="birth_date",
|
||||||
|
ttl_sec=365 * 86_400, # effectively permanent once known
|
||||||
|
cold_start_default=None,
|
||||||
|
min_history=999_999, # never inferred from events — sourced externally
|
||||||
|
infer=None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StarsAgent(BaseAgent):
|
||||||
|
"""Produces astrological transit predictions for the user's birth chart."""
|
||||||
|
|
||||||
|
agent_id: ClassVar[str] = MANIFEST.id
|
||||||
|
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||||
|
version: ClassVar[str] = MANIFEST.version
|
||||||
|
|
||||||
|
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||||
|
birth_date_str: str | None = inp.agent_prefs.get("birth_date")
|
||||||
|
|
||||||
|
if not birth_date_str:
|
||||||
|
prompt = (
|
||||||
|
"Birth date is not available — astrological reading skipped. "
|
||||||
|
"(Always write the tip in English.)"
|
||||||
|
)
|
||||||
|
return self._make_output(inp, prompt, {"no_birth_date": True})
|
||||||
|
|
||||||
|
if not _SWE_AVAILABLE:
|
||||||
|
prompt = (
|
||||||
|
"Astrological library unavailable — reading skipped. "
|
||||||
|
"(Always write the tip in English.)"
|
||||||
|
)
|
||||||
|
return self._make_output(inp, prompt, {"swe_unavailable": True})
|
||||||
|
|
||||||
|
try:
|
||||||
|
birth_date = date.fromisoformat(birth_date_str)
|
||||||
|
except ValueError:
|
||||||
|
prompt = "Birth date format invalid — astrological reading skipped."
|
||||||
|
return self._make_output(inp, prompt, {"invalid_birth_date": birth_date_str})
|
||||||
|
|
||||||
|
today_date = inp.now.date()
|
||||||
|
natal_jd = _jd_from_date(birth_date)
|
||||||
|
today_jd = _jd_from_date(today_date)
|
||||||
|
|
||||||
|
natal_pos = _planet_positions(natal_jd)
|
||||||
|
today_pos = _planet_positions(today_jd)
|
||||||
|
|
||||||
|
transits = _find_transits(natal_pos, today_pos)
|
||||||
|
top = transits[:3] # most exact transits only
|
||||||
|
|
||||||
|
today_sun_sign = _zodiac_sign(today_pos["Sun"])
|
||||||
|
natal_sun_sign = _zodiac_sign(natal_pos["Sun"])
|
||||||
|
natal_moon_sign = _zodiac_sign(natal_pos["Moon"])
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
"birth_date": birth_date_str,
|
||||||
|
"today": today_date.isoformat(),
|
||||||
|
"natal_sun": natal_sun_sign,
|
||||||
|
"natal_moon": natal_moon_sign,
|
||||||
|
"today_sun": today_sun_sign,
|
||||||
|
"active_transits": transits[:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not top:
|
||||||
|
prompt = (
|
||||||
|
f"Natal chart: Sun in {natal_sun_sign}, Moon in {natal_moon_sign}. "
|
||||||
|
f"Today's Sun is in {today_sun_sign}. "
|
||||||
|
"No exact transits today — a quiet, stable day energetically. "
|
||||||
|
"(Always write the tip in English.)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
transit_lines = "; ".join(_format_transit(t) for t in top)
|
||||||
|
prompt = (
|
||||||
|
f"Natal chart: Sun in {natal_sun_sign}, Moon in {natal_moon_sign}. "
|
||||||
|
f"Today's Sun is in {today_sun_sign}. "
|
||||||
|
f"Active transits: {transit_lines}. "
|
||||||
|
"Use these planetary themes to colour the tip — "
|
||||||
|
"keep it grounded and actionable, not predictive or fatalistic. "
|
||||||
|
"(Always write the tip in English.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._make_output(inp, prompt, snapshot)
|
||||||
@@ -14,6 +14,7 @@ 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.tarot import TarotAgent, _daily_draw, _CARDS, _POSITIONS
|
||||||
|
from ml.agents.stars import StarsAgent, _SWE_AVAILABLE
|
||||||
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
|
||||||
@@ -297,13 +298,56 @@ class TestTarotAgent:
|
|||||||
assert all(0 <= i < len(_CARDS) for i in indices)
|
assert all(0 <= i < len(_CARDS) for i in indices)
|
||||||
|
|
||||||
|
|
||||||
|
# ── StarsAgent ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestStarsAgent:
|
||||||
|
agent = StarsAgent()
|
||||||
|
|
||||||
|
def test_no_birth_date(self):
|
||||||
|
out = self.agent.compute(_inp())
|
||||||
|
_check_output(out, self.agent)
|
||||||
|
assert out.signals_snapshot.get("no_birth_date") is True
|
||||||
|
assert "birth date" in out.prompt_text.lower()
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
|
||||||
|
def test_invalid_birth_date(self):
|
||||||
|
out = self.agent.compute(_inp(agent_prefs={"birth_date": "not-a-date"}))
|
||||||
|
_check_output(out, self.agent)
|
||||||
|
assert out.signals_snapshot.get("invalid_birth_date") == "not-a-date"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
|
||||||
|
def test_with_birth_date(self):
|
||||||
|
out = self.agent.compute(_inp(agent_prefs={"birth_date": "1990-06-15"}))
|
||||||
|
_check_output(out, self.agent)
|
||||||
|
assert "natal" in out.prompt_text.lower()
|
||||||
|
assert out.signals_snapshot["birth_date"] == "1990-06-15"
|
||||||
|
assert "natal_sun" in out.signals_snapshot
|
||||||
|
assert "natal_moon" in out.signals_snapshot
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _SWE_AVAILABLE, reason="pyswisseph not installed")
|
||||||
|
def test_transit_snapshot_structure(self):
|
||||||
|
out = self.agent.compute(_inp(agent_prefs={"birth_date": "1985-03-21"}))
|
||||||
|
snap = out.signals_snapshot
|
||||||
|
assert "active_transits" in snap
|
||||||
|
for t in snap["active_transits"]:
|
||||||
|
assert {"transit_planet", "natal_planet", "aspect", "nature", "orb"} <= t.keys()
|
||||||
|
|
||||||
|
def test_swe_unavailable_path(self, monkeypatch):
|
||||||
|
import ml.agents.stars as stars_mod
|
||||||
|
monkeypatch.setattr(stars_mod, "_SWE_AVAILABLE", False)
|
||||||
|
agent = StarsAgent()
|
||||||
|
out = agent.compute(_inp(agent_prefs={"birth_date": "1990-06-15"}))
|
||||||
|
_check_output(out, agent)
|
||||||
|
assert out.signals_snapshot.get("swe_unavailable") is True
|
||||||
|
|
||||||
|
|
||||||
# ── 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", "tarot"}
|
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals", "tarot", "stars"}
|
||||||
|
|
||||||
def test_get_agent(self):
|
def test_get_agent(self):
|
||||||
a = get_agent("momentum")
|
a = get_agent("momentum")
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ nats-py>=2.9.0
|
|||||||
structlog>=24.1.0
|
structlog>=24.1.0
|
||||||
sentry-sdk>=2.0.0
|
sentry-sdk>=2.0.0
|
||||||
mlflow-skinny>=3.1.0
|
mlflow-skinny>=3.1.0
|
||||||
|
pyswisseph>=2.10.3.2
|
||||||
|
|||||||
Reference in New Issue
Block a user