Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/design/morning.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Three things hold it together:
- **The format is enforced in code, with one redraft** (`_is_structured`, same remedy as `_NAMES_THE_LINK`), and only when there is something to bullet (`need = min(2, len(required))`) — a user with nothing loaded still gets a plain greeting. A redraft only replaces the draft if it is better formed, so a fix for one rule cannot break another.
- **Length is capped at a line boundary** (`_tidy_morning_text`, `MORNING_LINE_MAX` 560). Whitespace collapses within a line but newlines survive; the old single-space join would have flattened the bullets, and a mid-line cut leaves half a fact.

**Weather and markets are in every update.** WEATHER is required whenever the payload has weather (it needs a city). MARKETS is required whenever the payload has a price row, and a user who follows no ticker gets one anyway: `home._fetch_prices` falls back to `tickers.DEFAULT_MARKETS` (S&P 500 and Dow) only when *nothing* in their topics resolved to a symbol. Tickers that resolve but fail to fetch do not trigger it — those are the user's chosen list, and a rate-limit blip must not swap it for someone else's. A user who hid the Markets section gets neither the fallback nor the requirement. The same payload feeds the page, so their Markets card shows the indices too.

Two properties are load-bearing:
- **The URL is last and alone.** Message apps only draw the rich link preview when the message carries exactly one URL at a boundary, and that preview is most of the value. Nothing may follow it — not a period, not a sign-off.
- **`carries_link` gates the status callback.** A link message is sent with `add_status_callback=False`, because the `/sms-status` shorten-and-retry would truncate the URL into garbage.
Expand Down
9 changes: 8 additions & 1 deletion palmer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,14 @@ def _build_system(phone: str, include_recent: bool = False, is_new_user: bool =
# once — userprofile._update_profile marks score_offer_sent under this
# same condition after the turn, so this never becomes a nag.
teams = profile.get("sports_teams")
teams = ", ".join(teams) if isinstance(teams, list) else str(teams)
# sports_teams is meant to be prose, but a profile can hold follow-list
# dicts there (the old `teams` alias migrated them in). Join names, or
# this block raises and takes the whole turn with it.
if isinstance(teams, list):
teams = ", ".join(t.get("name", "") if isinstance(t, dict) else str(t)
for t in teams)
else:
teams = str(teams)
system += (
"\n\nLIVE SCORES OFFER\n"
f"They've mentioned a team they follow ({teams}) and nothing is set up for it. "
Expand Down
10 changes: 9 additions & 1 deletion palmer/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,22 @@ def _fetch_prices(profile: dict, previous: list[dict] | None = None) -> list[dic
exactly like Palmer forgetting, and is far worse than a slightly stale
number sitting under a visible "N min ago" stamp."""
from palmer.datafeeds import price_snapshot
from palmer.tickers import resolve_topic_asset
from palmer.tickers import resolve_topic_asset, DEFAULT_MARKETS
stale = {(p.get("label") or "").lower(): p for p in (previous or [])}
assets, seen = [], set()
for topic in (profile.get("morning_topics") or []):
got = resolve_topic_asset(topic)
if got and got[0].lower() not in seen:
seen.add(got[0].lower())
assets.append(got)
# Nothing resolved to a symbol: show the broad market rather than an empty
# section, so every user's morning has a markets line. Only when NOTHING
# resolved — someone tracking tickers whose fetches all failed chose those
# tickers, and indices would be somebody else's list. A user who hid the
# section asked not to see it.
hidden = (profile.get("morning_prefs") or {}).get("hidden_sections") or []
if not assets and "markets" not in hidden:
assets = list(DEFAULT_MARKETS)
out = []
for symbol, label in assets[:MAX_PRICES]:
snapshot = price_snapshot(symbol, label) or stale.get(label.lower())
Expand Down
2 changes: 1 addition & 1 deletion palmer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ async def artifact_page(token: str):
async def icon_png():
"""The page's favicon and apple-touch-icon. No token: it is the same mark
for everyone and carries no one's data."""
from cards import render_icon
from palmer.cards import render_icon
return Response(content=render_icon(), media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"})

Expand Down
6 changes: 5 additions & 1 deletion palmer/morning.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,10 @@ def generate_morning_line(phone: str, payload: dict) -> str:
required.append("the commute")
if score_lines(payload):
required.append("their team — yesterday's result and/or today's game, with the score or the time")
hidden = (payload.get("page_prefs") or {}).get("hidden_sections") or []
if payload.get("prices") and "markets" not in hidden:
required.append("the markets — how each price row below moved, with its percent "
"(for most people that is just the S&P 500 and the Dow)")
if payload.get("opening"):
required.append("1-2 things newly open or worth catching near them this week, named specifically")
required_block = (
Expand All @@ -591,7 +595,7 @@ def generate_morning_line(phone: str, payload: dict) -> str:
Rules:
- Cover every REQUIRED item above, in your own words, with real specifics — the actual temperature, the actual commute time, the actual name of the place or event. Never gesture at a category ("some stuff opened nearby") instead of naming it.
- FORMAT: one opener line, then one bullet per item. The opener is a single short sentence in Palmer's voice (the day's character, a greeting, or the one thing that stands out) carrying no data the bullets also carry. Each bullet is its own line, exactly "- LABEL: text", where LABEL is ALL CAPS and one of: WEATHER, COMMUTE, NEARBY, SCORES, MARKETS, NEWS. Use WEATHER for weather, COMMUTE for the drive, NEARBY for openings/events, SCORES for a followed game. Only include a bullet for an item the data actually has. Each bullet is one tight line of plain words, not a clipped fragment. No blank lines between bullets.
- Beyond the required items, you may add ONE more bullet (MARKETS or NEWS) about something else on the page ONLY if it is genuinely notable — a real change, not routine. Skip it rather than padding; this text is basics plus link, not the briefing.
- Beyond the required items, you may add ONE more NEWS bullet about something else on the page ONLY if it is genuinely notable — a real change, not routine. Skip it rather than padding; this text is basics plus link, not the briefing.
- Under {MORNING_LINE_MAX} characters total, counting every line. Tight and scannable.
- Never say the word "link", "page", "dashboard", "site", or "click" - the link speaks for itself and naming it makes this sound like a product notification.
- Write ONLY the text. The link is attached automatically after it. Do not write a URL, and do not leave a placeholder like [link] or (url) where you think one goes - anything like that ships to them as literal text.
Expand Down
10 changes: 10 additions & 0 deletions palmer/tickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,16 @@

# Symbol -> how it should read on the page. Yahoo's index symbols are correct
# but unreadable; nobody wants "^GSPC" in their Markets section.
# What the Markets section shows for someone who follows no ticker at all, so
# every morning carries a market line. Hand-mapped like INDEX_TICKERS: search
# returns futures for indices, which would be the wrong number.
DEFAULT_MARKETS = (("^GSPC", "S&P 500"), ("^DJI", "Dow"))

INDEX_LABELS = {
"^GSPC": "S&P 500",
"^DJI": "Dow",
"^IXIC": "Nasdaq",
"^NDX": "Nasdaq 100",
"^RUT": "Russell 2000",
"^VIX": "VIX",
}
Expand All @@ -62,6 +68,10 @@
INDEX_TICKERS = {
"s&p 500": "^GSPC", "s&p500": "^GSPC", "sp500": "^GSPC", "s&p": "^GSPC",
"dow jones": "^DJI", "the dow": "^DJI",
# Nasdaq 100 is not the Nasdaq Composite. Without these keys "nasdaq 100
# updates" matched the bare "nasdaq" below and quietly served ^IXIC under
# the label "Nasdaq" — a different index, with nothing to show it.
"nasdaq 100": "^NDX", "nasdaq-100": "^NDX", "nasdaq100": "^NDX", "ndx": "^NDX",
"nasdaq": "^IXIC", "russell 2000": "^RUT", "vix": "^VIX",
"stock market": "^GSPC", "the market": "^GSPC", "markets": "^GSPC",
}
Expand Down
33 changes: 33 additions & 0 deletions tests/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,39 @@ def test_a_removed_ticker_does_not_come_back_from_the_stale_set(self):
assert [p["label"] for p in out] == ["NVDA"]


class TestMarketsFallback:
"""Everyone's morning carries a market line. Someone who follows no ticker
gets the S&P 500 and the Dow; someone who does keeps exactly their own."""

def _labels(self, profile, snap=None):
snap = snap or (lambda sym, label=None: {"label": label, "price": 1.0})
with patch("palmer.datafeeds.price_snapshot", side_effect=snap) as ps:
out = home._fetch_prices(profile, None)
return [p["label"] for p in out], ps

def test_no_topics_shows_the_broad_market(self):
labels, ps = self._labels({})
assert labels == ["S&P 500", "Dow"]
assert [c.args[0] for c in ps.call_args_list] == ["^GSPC", "^DJI"]

def test_topics_that_name_no_ticker_still_get_it(self):
labels, _ = self._labels({"morning_topics": ["Philadelphia Eagles news", "SpaceX news"]})
assert labels == ["S&P 500", "Dow"]

def test_a_tracked_ticker_replaces_it_entirely(self):
labels, _ = self._labels({"morning_topics": ["Nvidia stock"]})
assert labels == ["NVDA"]

def test_tracked_tickers_that_fail_do_not_fall_back_to_indices(self):
"""They chose those tickers; a blip is not a reason to swap the list."""
with patch("palmer.datafeeds.price_snapshot", return_value=None):
assert home._fetch_prices({"morning_topics": ["Nvidia stock"]}, None) == []

def test_a_user_who_hid_markets_gets_nothing(self):
labels, ps = self._labels({"morning_prefs": {"hidden_sections": ["markets"]}})
assert labels == [] and ps.call_count == 0


class TestInvalidate:
""""add apple stock" has to show up now, not in five minutes."""

Expand Down
13 changes: 13 additions & 0 deletions tests/test_morning.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ def test_opening_is_not_required_without_data(self):
body = self._required_block(payload)
assert "newly open" not in body

def test_markets_are_required_when_there_is_a_price_row(self):
body = self._required_block(dict(PAYLOAD, prices=[{"label": "S&P 500", "pct_24h": -0.4},
{"label": "Dow", "pct_24h": -0.2}]))
assert "the markets" in body and "S&P 500 and the Dow" in body

def test_markets_are_not_required_without_prices(self):
body = self._required_block({k: v for k, v in PAYLOAD.items() if k != "prices"})
assert "the markets" not in body

def test_markets_are_not_required_when_the_user_hid_them(self):
payload = dict(PAYLOAD, page_prefs={"section_order": [], "hidden_sections": ["markets"]})
assert "the markets" not in self._required_block(payload)

def test_no_structured_data_falls_back_to_a_plain_greeting_instruction(self):
body = self._required_block({})
assert "plain greeting is correct" in body
Expand Down
7 changes: 7 additions & 0 deletions tests/test_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,13 @@ def test_a_company_in_a_news_topic_gets_no_ticker(self, topic):
def test_the_same_company_with_a_price_word_does_resolve(self, topic, symbol):
assert resolve(topic)[0] == symbol

def test_nasdaq_100_is_not_the_composite(self):
""""give me nasdaq 100 updates" matched the bare "nasdaq" key and served
^IXIC labelled "Nasdaq" — the wrong index, silently."""
for topic in ("Nasdaq 100 updates", "nasdaq-100", "NDX", "Nasdaq 100 index price"):
assert resolve(topic) == ("^NDX", "Nasdaq 100"), topic
assert resolve("Nasdaq composite")[0] == "^IXIC"

def test_indices_need_no_price_word(self):
""""nasdaq" is unambiguously a market reference on its own."""
assert resolve("nasdaq")[0] == "^IXIC"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,3 +1037,11 @@ def test_the_description_leads_with_the_weather_when_named(self):

def test_the_icon_is_a_png(self):
assert cards.render_icon()[:8] == b"\x89PNG\r\n\x1a\n"

def test_the_icon_route_serves_it(self):
"""The route imported `cards` by its pre-package name and 500'd in
production while every unit test of render_icon passed."""
from fastapi.testclient import TestClient
from palmer.main import app
r = TestClient(app).get("/icon.png")
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
12 changes: 12 additions & 0 deletions tests/test_sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ def test_the_tool_descriptions_agree(self):
assert "without unfollowing" in setter["description"]
assert setter["input_schema"]["required"] == ["mode"]

def test_follow_dicts_in_sports_teams_do_not_crash_the_prompt(self):
"""A production profile held team dicts in sports_teams; the offer block
joined them as strings and every turn for that user raised."""
from palmer import agent
base = {"intro_sent": True, "name": "Jeff", "city": "Kirkwood, MO",
"sports_teams": [{"league": "mlb", "abbrev": "STL", "name": "St. Louis Cardinals"},
{"league": "nfl", "abbrev": "CHI", "name": "Chicago Bears"}]}
with patch.object(agent, "get_profile", return_value=dict(base)), \
patch.object(agent, "get_history", return_value=[]):
system = agent._build_system("+1")
assert "St. Louis Cardinals, Chicago Bears" in system

def test_the_offer_block_fires_once_for_a_named_team(self):
from palmer import agent
from palmer import userprofile
Expand Down
Loading