Skip to content
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
29 changes: 29 additions & 0 deletions docs/design/opening.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ Three things are load-bearing:
on Haiku, once per city, cached — deliberately *not* a lookup table, which is
the mistake `tickers.py` made twice with `PRIVATE_COMPANIES`. Ticketmaster
sidesteps the problem entirely by taking `latlong` + radius instead of a name.
- **A smaller metro came back all concerts, and four things caused it.** St.
Louis showed Black Country New Road, D.L. Hughley and Journey, week after week.
(1) `_local_candidates` was `trusted_only`, and stltoday, stlpr, stlmag, fox2now
and ksdk are the real newsrooms and none is on the allowlist — it returned
nothing, so the whole press half was silently empty. It now runs untrusted at
`LOCAL_MIN_SCORE` 0.60 (`_search_raw` still sorts trusted first) over four
queries — openings, "festivals fairs events {Month Year}", "festival this
weekend", "family events … this weekend" — on a 21-day window, because the
14-day one made the same query swing between zero and eight hits. (2)
Ticketmaster was Music and Arts only; Miscellaneous (fairs, festivals, family
shows) is added, Sports deliberately is not (regular-season fixtures belong to
people who follow the team, which is a per-user question). (3) One shared
prompt cut at 12k characters meant ~8.6k of concerts and a clipped sliver of
press; `_fit` now holds each family to its own budget and drops whole items
rather than cutting one mid-JSON. (4) With both families in one call the model
took the first good candidates it met, which were the concerts, whatever the
prompt said about spread — so events and press are **curated in separate
calls** (`EVENT_ROWS`/`PRESS_ROWS` 5 each, `_interleave`d), and the per-topic
cap (`MAX_PER_TOPIC` 2) is enforced in code. Rows carry a `topic`
(music, comedy, theatre, food, festival, family, film, other) and `_spread`
takes one of each topic before a second at read time.
- **A press-sourced event must carry a date or it is dropped.** The curator lifts
individual events out of weekend roundups, and a date there is the model's
reading of prose. Undated, a row can never expire, and "Oct 9-11" was served on
the 14th. The prompt asks for the LAST day of a multi-day event; Ticketmaster
rows have a real date upstream and are exempt. Cost: Tavily goes from 2 to 4
searches per metro per week and Haiku from 1 to 2 calls per metro per day.
Preference-driven ordering (family, teams, what the user has said) is the
next layer and is not built; today every user in a metro shares one ranking.
- **The local outlets had to be added to `trusted_sources.json`.** Palmer Home
passes `trusted_only=True`, and Eater, Time Out, LAist, Thrillist and the city
dailies were all tier 3 — so before they were added the section returned
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
Loading
Loading