From 015d7657c808afcab056ed86daf8d8655b0db72c Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 20:04:05 -0500 Subject: [PATCH 1/4] Fix /icon.png: import cards from the palmer package The route was written before the package move and its lazy import was not converted, so it 500'd in production. Adds a route-level test. Co-Authored-By: Claude Sonnet 5.5 --- palmer/main.py | 2 +- tests/test_page.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/palmer/main.py b/palmer/main.py index a034a21..73bb23f 100644 --- a/palmer/main.py +++ b/palmer/main.py @@ -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"}) diff --git a/tests/test_page.py b/tests/test_page.py index 7849638..603e429 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -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" From 1ad1fd544b52887089549d12e46595ab657223ae Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 20:07:18 -0500 Subject: [PATCH 2/4] Don't crash the prompt when sports_teams holds follow dicts The live-scores offer block joined sports_teams as strings; a profile with dicts there raised in _build_system, failing every reply and the morning text for that user. Co-Authored-By: Claude Sonnet 5.5 --- palmer/agent.py | 9 ++++++++- tests/test_sports.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/palmer/agent.py b/palmer/agent.py index d278dab..feb52ee 100644 --- a/palmer/agent.py +++ b/palmer/agent.py @@ -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. " diff --git a/tests/test_sports.py b/tests/test_sports.py index c6ee823..1da1b23 100644 --- a/tests/test_sports.py +++ b/tests/test_sports.py @@ -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 From 6b3ef18bf4c199c4ed43baa0a30d62703e4a10e9 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 20:12:17 -0500 Subject: [PATCH 3/4] Markets in every morning: S&P 500 and Dow when no ticker is followed home._fetch_prices falls back to the broad market only when nothing in the user's topics resolved to a symbol (and they have not hidden Markets); the morning text now requires a MARKETS bullet whenever there is a price row. Co-Authored-By: Claude Sonnet 5.5 --- docs/design/morning.md | 2 ++ palmer/home.py | 10 +++++++++- palmer/morning.py | 6 +++++- palmer/tickers.py | 5 +++++ tests/test_home.py | 33 +++++++++++++++++++++++++++++++++ tests/test_morning.py | 13 +++++++++++++ 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/design/morning.md b/docs/design/morning.md index c9efd13..0d1fd78 100644 --- a/docs/design/morning.md +++ b/docs/design/morning.md @@ -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. diff --git a/palmer/home.py b/palmer/home.py index 6f7e52f..0853edb 100644 --- a/palmer/home.py +++ b/palmer/home.py @@ -192,7 +192,7 @@ 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 []): @@ -200,6 +200,14 @@ def _fetch_prices(profile: dict, previous: list[dict] | None = None) -> list[dic 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()) diff --git a/palmer/morning.py b/palmer/morning.py index b3de9da..97829d6 100644 --- a/palmer/morning.py +++ b/palmer/morning.py @@ -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 = ( @@ -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. diff --git a/palmer/tickers.py b/palmer/tickers.py index 5b71305..8877d8d 100644 --- a/palmer/tickers.py +++ b/palmer/tickers.py @@ -46,6 +46,11 @@ # 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", diff --git a/tests/test_home.py b/tests/test_home.py index f16da5d..9ffe8f4 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -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.""" diff --git a/tests/test_morning.py b/tests/test_morning.py index ebd42e1..ac3849e 100644 --- a/tests/test_morning.py +++ b/tests/test_morning.py @@ -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 From dd19043641f08fe79a67e68de0d107f4cf4015b8 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Wed, 14 Oct 2026 20:14:15 -0500 Subject: [PATCH 4/4] Nasdaq 100 resolves to ^NDX, not the Composite Co-Authored-By: Claude Sonnet 5.5 --- palmer/tickers.py | 5 +++++ tests/test_news.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/palmer/tickers.py b/palmer/tickers.py index 8877d8d..38fee65 100644 --- a/palmer/tickers.py +++ b/palmer/tickers.py @@ -55,6 +55,7 @@ "^GSPC": "S&P 500", "^DJI": "Dow", "^IXIC": "Nasdaq", + "^NDX": "Nasdaq 100", "^RUT": "Russell 2000", "^VIX": "VIX", } @@ -67,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", } diff --git a/tests/test_news.py b/tests/test_news.py index f73ef11..3e442be 100644 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -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"