From 1170bed36ea697cdebcd1ed67534837d5c2c530a Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Mon, 14 Sep 2026 14:32:42 -0500 Subject: [PATCH] Repo hygiene: license, CI, pinned deps, complete env example, no personal data in source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code was fine; the packaging around it read as a prototype. This is the first of four cleanup stages (hygiene, package layout, test consolidation, docs split) ahead of scaling Palmer. - LICENSE (all rights reserved, source available for reading) and a README License section. The repo is public and Palmer is a product. - .github/workflows/ci.yml runs ruff and the full suite on every push and PR. The suite is offline; a few modules read their API key at import time, so the workflow sets placeholder values rather than secrets. - requirements.txt pinned to the versions the venv actually runs, and `requests` added — it is imported directly by three modules and only resolved through a transitive dependency. requirements-dev.txt adds pytest and ruff. - .env.example lists all 14 variables the code reads. It listed five, and TAVILY_API_KEY was not one of them, so a fresh clone failed on import. Verified: the suite passes in a clean environment holding only these placeholders. - README: price watches run twice daily on a cron and alert on any move over a flat $2 in either direction, not "every 6h" on a "15% drop" — the code changed twice and the prose did not. Same stale claim fixed in shopping.py's docstrings. Added a Development section and the CI badge. - Personal data out of the source tree: the NWS User-Agent contact is now PALMER_CONTACT_EMAIL; guards.py and two test files no longer carry real users' names, neighbourhoods or verbatim texts; the one non-555 phone number in the suite is now a reserved one. The repetition fixture keeps a two-word city because opening_shape compares three meaningful words. - Dead code removed, each verified to have no caller: artifacts.publish, db.get_all_phones, home.home_url, cards.AMBER, shows.resolve_show (the follow_show dispatch uses find_shows) and its two tests. wxaudit gets a __main__ so `python wxaudit.py` prints the scoreboard. send_morning.py gets a __main__ guard — importing it sent real SMS. - Five comments still pointed at the deleted alerts.py; reworded. - ruff.toml selects E/F/W and deliberately ignores line length, imports below load_dotenv()/init_db(), and the `conn = _conn(); cur = ...` house style, so the linter enforces mistakes rather than taste. Kept on purpose: shopping._daily_ok mirrors watches._daily_ok, but shopping sits below agent in the import graph and watches above it, so sharing one definition would invert the dependency direction. Flagged, not fixed: db.get_user_flight_watches has no caller because list_watches never lists flight watches — a user can add one and never see it again. That is a product gap for its own PR. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018QpUhnYC3U7KxF8QCeenfQ --- .env.example | 23 +++++++++++++++++++++-- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ .gitignore | 4 ++++ CLAUDE.md | 12 ++++++------ LICENSE | 14 ++++++++++++++ README.md | 27 +++++++++++++++++++++++++-- agent.py | 5 ++--- artifacts.py | 9 +-------- cards.py | 1 - datafeeds.py | 2 +- db.py | 13 ++----------- flightwatch.py | 5 ++--- guards.py | 16 ++++++++-------- home.py | 4 ---- page.py | 1 - requirements-dev.txt | 3 +++ requirements.txt | 23 ++++++++++++----------- ruff.toml | 10 ++++++++++ send_morning.py | 14 +++++++++++--- shopping.py | 9 +++++---- shows.py | 8 +------- test_add_price_watch_tool.py | 6 ++++-- test_calibration.py | 2 +- test_clock_correctness.py | 1 - test_commute.py | 2 +- test_guards_and_flights.py | 21 ++++++++++++++++----- test_home.py | 1 - test_local_day_guards.py | 16 +++++++++------- test_morning_link.py | 1 - test_my_page_tool.py | 6 +++--- test_opening.py | 3 ++- test_page.py | 9 +++++---- test_phantom_history.py | 4 ++-- test_profile_freshness.py | 4 ++-- test_reminder_time.py | 2 -- test_repetition.py | 14 +++++++------- test_shows.py | 15 ++------------- test_sports.py | 2 +- test_watches_story.py | 2 +- test_weather_locations.py | 3 ++- userprofile.py | 1 - watches.py | 8 ++++---- weather.py | 10 ++++++---- wxaudit.py | 6 +++++- 44 files changed, 231 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 requirements-dev.txt create mode 100644 ruff.toml diff --git a/.env.example b/.env.example index a2c15df..33b71d5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,24 @@ +# Copy to .env and fill in. Every variable the code reads is listed here. + +# Required — the app will not import without these ANTHROPIC_API_KEY=sk-ant-... TWILIO_ACCOUNT_SID=AC... TWILIO_AUTH_TOKEN=... -TWILIO_PHONE_NUMBER=+1... -SERP_API_KEY=... +TWILIO_PHONE_NUMBER=+15551234567 +TAVILY_API_KEY=tvly-... # news search; read at import time + +# Deployment +APP_URL=https://your-app.herokuapp.com # Twilio status callbacks, Palmer Home links +# DATABASE_URL=postgres://... # set by Heroku; omit locally to use SQLite (palmer.db) +PALMER_CONTACT_EMAIL=you@example.com # sent to the National Weather Service in the User-Agent, which NWS requires + +# Features — each degrades gracefully when its key is absent +TOMTOM_API_KEY=... # traffic, drive times, the commute +SERP_API_KEY=... # product price watches, shopping, flights, hotels (SerpAPI) +GIPHY_API_KEY=... # GIFs +TMDB_API_KEY=... # movies and shows in the Opening section (free, non-commercial) +TICKETMASTER_API_KEY=... # events in the Opening section + +# Development flags +# PALMER_NO_SCHEDULER=1 # import main without starting the job loop (tests, shells, one-off scripts) +# DATA_ASK_DRY_RUN=1 # preview the missing-city asks without sending diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8546dd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + env: + # Placeholders only. The suite is fully offline; a few modules read their + # key at import time and simply need the variable to exist. + ANTHROPIC_API_KEY: test + TWILIO_ACCOUNT_SID: ACtest + TWILIO_AUTH_TOKEN: test + TWILIO_PHONE_NUMBER: "+15550000000" + TAVILY_API_KEY: test + TOMTOM_API_KEY: test + SERP_API_KEY: test + PALMER_NO_SCHEDULER: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + cache: pip + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: pytest -q diff --git a/.gitignore b/.gitignore index 2979eb8..b4619d9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ package-lock.json # .env backups — these carry live keys and must never be committed .env.bak* .env.*.bak + +# tool caches +.pytest_cache/ +.ruff_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 8bd9792..8bb974a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Palmer is a personal AI delivered entirely over SMS via Twilio. A FastAPI web dy ## Running / commands ```bash -# Install (uses .python-version, currently 3.12) +# Install (uses .python-version, currently 3.11) python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt @@ -79,7 +79,7 @@ Dependencies run strictly downward: `llm`/`netutil`/`sources` ← `smstext`/`wea Underscore prefixes still mean "internal to Palmer", not "private to this module" — `smstext._sms_clean` is imported by six modules. Grep before renaming. -**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target does not fail loudly — it lets the test make real API calls. Watch the suite runtime: 1058 tests in ~10s, and a jump means something is hitting the network. +**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target does not fail loudly — it lets the test make real API calls. Watch the suite runtime: ~1,400 tests in ~6s, and a jump means something is hitting the network. ### Scheduler cadence (main.py) ``` @@ -666,7 +666,7 @@ Three rules came from the spec and each has a test that catches its reversal: (`MAX_EPISODES` takes its slots from `MAX_SCREENS`). A show you actually watch is worth more than a film chosen for you, and the row count stays put. -Resolution runs on the **write** path (`resolve_show`, one TMDB search when the +Resolution runs on the **write** path (`find_shows`, one TMDB search when the user follows), never on read — same terms as `_normalize_price_topic` and `_city_from_weather_topic`. An unresolvable title asks the user to confirm; it never guesses one and never sends them elsewhere to look it up. @@ -966,7 +966,7 @@ code reading it gets strings where it expects dicts. `migrate_profile_prune.py` cleans rows that grew before the allow-list existed. It folds the stray keys into canonical fields with a Sonnet pass before dropping them, so real facts survive. Dry run by default; `--apply` writes. ### DB access patterns -- `get_all_profiles()` returns every `(phone, profile)` in ONE query. The scheduler jobs use it. Do not write `for phone in get_all_phones(): get_profile(phone)` — `_conn()` opens a fresh connection per call, so that is N+1 per tick. +- `get_all_profiles()` returns every `(phone, profile)` in ONE query. The scheduler jobs use it. Do not loop over phones calling `get_profile(phone)` per user — `_conn()` opens a fresh connection per call, so that is N+1 per tick. - `upsert_profile()` does its read and write on one connection, and takes a row lock on Postgres. It used to be two connections with an unsynchronised gap, so concurrent writers could drop each other's fields. - Pass profiles down rather than re-reading them. The reaction path once cost five `get_profile` calls for a single inbound tapback. @@ -983,7 +983,7 @@ The system prompt in `agent.py` hard-routes user asks to specific tools. Never m - `get_price` → CoinGecko (crypto) / yfinance (stocks) only - `get_travel_time` / `get_city_traffic` → TomTom only - `set_commute` / `clear_commute` → the user's REGULAR drive; TomTom geocode on the write path, routed for their leave time by `home._fetch_traffic` -- `get_my_page` → the caller's own Palmer Home URL, via `home.ensure_fresh` (never `home_url` — that can hand out a link to a page that was never built) +- `get_my_page` → the caller's own Palmer Home URL, via `home.ensure_fresh` (never a bare URL builder — that can hand out a link to a page that was never built) - `add_price_watch` / `run_price_watches` → SerpAPI Google Shopping only (product prices, distinct from `get_price` for crypto/stocks) - `web_search` → Tavily news mode only, never for weather or prices @@ -1426,7 +1426,7 @@ to doubt a confirmation. `weather.ambiguous_location`, used on the **write** paths only. Note `resolve_weather_location`'s docstring has always claimed "None means the model should ask rather than guess" — that was only ever true when *nothing* matched. -- **Shows.** `resolve_show` took `results[0]` too, and `sports.py`'s own comment +- **Shows.** The show resolver took `results[0]` too, and `sports.py`'s own comment says naming a show is not ambiguous the way naming a team is. True of Reacher, false of The Office, Shameless, Skins and Ghosts. `shows.find_shows` returns matches with year and origin country; only a genuinely shared title is a diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e9f78e --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2026 Jeff Larson. All rights reserved. + +This source code is made available for reading, reference and evaluation only. + +No license is granted, express or implied, to copy, modify, merge, publish, +distribute, sublicense, sell, host, or run this software or any derivative of +it, in whole or in part, for any purpose, without prior written permission +from the copyright holder. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE. diff --git a/README.md b/README.md index 0d9a45b..1477281 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Palmer +[![CI](https://github.com/pythonjeff/Palmer/actions/workflows/ci.yml/badge.svg)](https://github.com/pythonjeff/Palmer/actions/workflows/ci.yml) + **The AI that texts like a friend who actually pays attention.** Palmer is a personal AI delivered entirely over SMS. No app to download, no interface to learn — just text it. It knows who you are, remembers what you care about, and reaches out when something worth knowing happens. @@ -92,7 +94,8 @@ cp .env.example .env | `TOMTOM_API_KEY` | developer.tomtom.com (free tier, ~2,500 requests/day) | | `SERP_API_KEY` | serpapi.com (free plan is 250 searches/mo; paid from $50/mo for 5,000) | | `APP_URL` | Your deployed app URL (for Twilio status callbacks) | -| `DATABASE_URL` | Postgres connection string (auto-set by Heroku) | +| `DATABASE_URL` | Postgres connection string (auto-set by Heroku; omit locally to use SQLite) | +| `PALMER_CONTACT_EMAIL` | Contact address sent in the User-Agent to the National Weather Service, which requires one | **3. Run locally** ```bash @@ -134,6 +137,20 @@ GET /preview?phone=+15551234567 # generate morning briefing without send --- +## Development + +```bash +pip install -r requirements-dev.txt +pytest # ~1,400 tests, all offline, a few seconds +ruff check . # lint +``` + +The tests need no real keys. A few modules read their API key at import time, so set placeholder values (`TAVILY_API_KEY=x`, `TOMTOM_API_KEY=x`, `SERP_API_KEY=x`, the Twilio and Anthropic vars) or copy `.env.example` to `.env`. `PALMER_NO_SCHEDULER=1` keeps the job loop from starting when `main` is imported in a shell or a one-off script. CI runs the same two commands on every push. + +Design notes — the reasoning behind each subsystem and the production incidents that shaped it — live in [`CLAUDE.md`](CLAUDE.md). + +--- + ## Architecture - `WEB_CONCURRENCY=1` is required. In-memory phone locks and APScheduler only work correctly in a single process. @@ -141,8 +158,14 @@ GET /preview?phone=+15551234567 # generate morning briefing without send - **Source quality is one gate, applied at the search call** (`sources.py`). Every news fact and link Palmer sends — watch alerts, the morning briefing, Palmer Home, and conversational web search — goes through `datafeeds._search_raw` / `_search`, which filter before returning, so no surface can drift to its own standard. Four gates: a **blocklist** dropping press-release wires (`prnewswire`, `globenewswire`) and republishing aggregators (`msn.com`, `biztoc`, `news.google.com`) outright; a **relevance floor** that gives trusted sources 0.15 of slack, because Tavily's score measures query-text match and that is exactly what an SEO content farm is built to win; **tier ordering** from `trusted_sources.json` (tier 1 = wires and premier newsrooms plus `.gov`/`.edu`, tier 2 = mainstream and reputable specialists, tier 3 = everything else) sorting `(tier, -score)` so a wire report beats a higher-scoring blog; and **corroboration** requiring ≥ 2 distinct domains OR ≥ 1 tier-1 source before any unprompted alert fires. Palmer Home additionally drops tier 3 entirely — an untrusted row is worse than no row on a short curated list. Edit `trusted_sources.json` to add or remove sources; no code change needed. - Watches run every 30 minutes via APScheduler but only alert on major breaking developments — dated results from the last 12 hours, a strict criticality gate, a HEAD reachability check so a dead top link falls through to the next result, per-watch cooldown (default 4 hours), and dedup against the last alerted event. - Traffic uses TomTom. Morning briefings auto-include a short city snapshot (`get_city_traffic`): geocode → parallel Traffic Flow + Traffic Incidents in a city bounding box → Haiku drafts one natural line, or skips silently on failure. On demand, users can ask for city conditions or point-to-point drive times (`get_travel_time`, live traffic vs. free-flow). Landmark destinations (White House, Fenway, LAX) get resolved to street addresses by Sonnet before geocoding — TomTom's geocoder is a mapping API, not a search engine, and mis-ranks landmark names. A regular commute is saved with `set_commute` (geocoded once on save, with an optional leave time); the page's Commute card and the morning text are then routed for that departure via TomTom `departAt`, and say whether the number is a forecast for it or live traffic. -- Price watches use SerpAPI Google Shopping (`shopping.py`): user texts "watch these sneakers under $80" → `add_price_watch` tool saves to `price_watches` → `run_price_watches` job runs every 6h. Each tick: SerpAPI Google Shopping query on the product name, Haiku picks the cheapest genuine match from the top candidates (guards against firing on unrelated accessories/refurbs), first successful check establishes a baseline silently, subsequent checks alert when the target is hit or the price drops ≥ 15% from baseline. Cooldown defaults to 12h. Fails silently on any SerpAPI error — never surfaces "shopping tool failed" to the user. +- Price watches use SerpAPI Google Shopping (`shopping.py`): user texts "watch these sneakers under $80" → `add_price_watch` tool saves to `price_watches` → `run_price_watches` job runs twice a day on a cron (00:00 and 16:00 UTC — a cron rather than an interval so a deploy cannot reset the clock). Each run: SerpAPI Google Shopping query on the product name, Haiku picks the cheapest genuine match from the top candidates (guards against firing on unrelated accessories/refurbs), first successful check establishes a baseline silently, subsequent checks alert when the target is hit or the price has moved more than $2 in either direction (a flat bar, deliberately not a percentage, so a $12 grocery item and a $1,200 laptop are held to the same standard), then re-baseline. Cooldown defaults to 12h. Fails silently on any SerpAPI error — never surfaces "shopping tool failed" to the user. - Proactive outbound is scheduled: **mornings** at each user's local time (5-min tick, catch-up window, per-day guard), **check-ins** on a 2h tick but paced in days (`followup.GAP_DAYS`; Haiku picks one subject from their threads, their team's game or the page's headlines by echoing it exactly, Sonnet drafts, 1–7pm window), and **missing-data asks** every 60 min for users onboarded without a city so mornings can target them (7-day cooldown, US-daytime UTC window; `DATA_ASK_DRY_RUN=1` to preview). - Reminder delivery uses `FOR UPDATE SKIP LOCKED` on Postgres — safe for multiple scheduler ticks, no double-sends. - Twilio webhook signatures (HMAC-SHA1) validated on every inbound request. All DB queries parameterized and scoped to phone number. - Tool routing is hard: `get_weather` → NWS/Open-Meteo only, `get_price` → CoinGecko/yfinance only, traffic tools → TomTom only, product price watches → SerpAPI only, web search → Tavily news mode. No overlap, no hallucinated data. + +--- + +## License + +Copyright © 2026 Jeff Larson. All rights reserved. The source is published for reading and evaluation; see [`LICENSE`](LICENSE). diff --git a/agent.py b/agent.py index 85becd7..9cfd621 100644 --- a/agent.py +++ b/agent.py @@ -14,8 +14,7 @@ from datetime import datetime, timedelta, timezone from db import ( - init_db, get_history, save_message, get_profile, upsert_profile, save_reminder, cancel_reminders, - HISTORY_LIMIT, + init_db, get_history, save_message, get_profile, upsert_profile, save_reminder, HISTORY_LIMIT, save_watch, get_user_watches, cancel_watches, save_price_watch, get_user_price_watches, cancel_price_watches, set_price_watch_baseline, ) @@ -745,7 +744,7 @@ def _dispatch(b): current = list(profile.get("weather_locations") or []) asked = (b.input.get("location") or "").strip() # Resolve on the WRITE path, once — never on read, which runs - # on every page view. Same terms as resolve_show/_normalize_price_topic. + # on every page view. Same terms as shows.find_shows/_normalize_price_topic. resolved = resolve_weather_location(asked) choices = ambiguous_location(asked) if not resolved: diff --git a/artifacts.py b/artifacts.py index c4f6c8b..6591e9c 100644 --- a/artifacts.py +++ b/artifacts.py @@ -30,7 +30,7 @@ import secrets import threading -from db import save_artifact, get_artifact +from db import get_artifact _APP_URL = os.environ.get("APP_URL", "").rstrip("/") @@ -48,13 +48,6 @@ def new_token() -> str: return secrets.token_urlsafe(16) -def publish(payload: dict, ttl_hours: int = TTL_HOURS) -> tuple[str, str]: - """Store a briefing payload. Returns (token, page_url).""" - token = new_token() - save_artifact(token, "briefing", json.dumps(payload).encode(), ttl_hours=ttl_hours) - return token, page_url(token) - - def page_url(token: str) -> str: return f"{_APP_URL}/c/{token}" diff --git a/cards.py b/cards.py index 071c780..85c9386 100644 --- a/cards.py +++ b/cards.py @@ -39,7 +39,6 @@ UP, DOWN = (31, 110, 58), (163, 39, 31) WARM = (168, 70, 26) COOL = (31, 90, 140) -AMBER = (138, 90, 16) _FONT_DIRS = ( "/usr/share/fonts/truetype/dejavu", # heroku slug diff --git a/datafeeds.py b/datafeeds.py index 7ee2de1..445aa20 100644 --- a/datafeeds.py +++ b/datafeeds.py @@ -7,7 +7,7 @@ import base64 import random import concurrent.futures -from datetime import datetime, timezone, timedelta, date as _date +from datetime import datetime, timezone, timedelta import requests as _requests from tavily import TavilyClient diff --git a/db.py b/db.py index 93d0dd9..bb25d27 100644 --- a/db.py +++ b/db.py @@ -316,15 +316,6 @@ def get_profile(phone: str) -> dict: return json.loads(row["profile"]) if row else {} -def get_all_phones() -> list[str]: - conn = _conn() - cur = conn.cursor() - cur.execute("SELECT phone FROM users") - phones = [r["phone"] for r in cur.fetchall()] - conn.close() - return phones - - def upsert_profile(phone: str, updates: dict): """Merge `updates` into the stored profile. @@ -577,7 +568,7 @@ def normalize_due_at_rows() -> int: which is worth seeing rather than silently rewriting.""" conn = _conn() cur = conn.cursor() - cur.execute(f"SELECT id, due_at FROM reminders WHERE sent = 0") + cur.execute("SELECT id, due_at FROM reminders WHERE sent = 0") fixed = 0 for row in cur.fetchall(): parsed = _parse_due(row["due_at"]) @@ -771,7 +762,7 @@ def update_watch_alerted(watch_id: int, summary: str, recent_summaries: list[str back by watches._daily_ok, and both used to key on the dyno's UTC day — so the cap window rolled at 17:00 Pacific, mid-evening, and a user could take the whole allowance across one local evening and be capped by lunch - the next day. Same defect alerts.py was fixed for.""" + the next day. Same defect the retired daily-alert job was fixed for.""" now = datetime.now(timezone.utc).isoformat() today = today or datetime.now(timezone.utc).date().isoformat() conn = _conn() diff --git a/flightwatch.py b/flightwatch.py index 5cf9eea..100724f 100644 --- a/flightwatch.py +++ b/flightwatch.py @@ -16,10 +16,9 @@ """ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import date -from db import (get_active_flight_watches, update_flight_watch_price, - get_profile) +from db import (get_active_flight_watches, update_flight_watch_price) # A fare has to move by more than this to be worth a text. Flights are volatile # by tens of dollars daily, so the flat $2 rule that governs product watches diff --git a/guards.py b/guards.py index 9945178..13a7021 100644 --- a/guards.py +++ b/guards.py @@ -88,14 +88,14 @@ def redirects_elsewhere(text: str) -> bool: # --- repetition -------------------------------------------------------------- # Two different failures wear the same face, and they need opposite remedies. # -# SUPPRESSION: an unprompted message repeating one already sent. Drew got the -# identical followup twice — "yo how'd practice look today? hurts moving like +# SUPPRESSION: an unprompted message repeating one already sent. One user got +# the identical followup twice — "yo how'd practice look today? hurts moving like # they said?" — because _is_duplicate_subject's window is six hours while the # followup job runs every four and the subject stayed live for days. # # VARIATION: a scheduled message the user DID ask for, said the same way every -# time. Three consecutive mornings opened "103 today in Woodland Hills", "106 in -# Woodland Hills today", "111 today in Woodland Hills". Suppressing those would +# time. Three consecutive mornings opened "103 today in Cedar Falls", "106 in +# Cedar Falls today", "111 today in Cedar Falls". Suppressing those would # be wrong — they asked for a daily briefing — but Palmer should not sound like # a form letter. # @@ -144,9 +144,9 @@ def near_duplicate(text: str, recent: list[str], threshold: float = REPEAT_THRES # Token overlap answers SUPPRESSION and is useless for VARIATION. Three -# consecutive Woodland Hills mornings — "Morning Drew - 103 today in Woodland -# Hills", "106 in Woodland Hills today, Drew", "111 today in Woodland Hills, -# Drew" — score only 0.23-0.25 against each other, because the numbers and the +# consecutive Cedar Falls mornings — "Morning Alex - 103 today in Cedar Falls", +# "106 in Cedar Falls today, Alex", "111 today in Cedar Falls, Alex" — score +# only 0.23-0.25 against each other, because the numbers and the # trailing clause differ every day. Nothing lexical separates them from a # genuinely fresh morning. # @@ -155,7 +155,7 @@ def near_duplicate(text: str, recent: list[str], threshold: float = REPEAT_THRES # words. That is the thing a reader recognises as "he says it the same way # every morning". # Three, not five. By the fourth word the trailing clause has diverged — "103 -# today in Woodland Hills, STAY INSIDE" vs "106 in Woodland Hills today, DREW — +# today in Cedar Falls, STAY INSIDE" vs "106 in Cedar Falls today, ALEX — # HOTTEST" — and every day looks unique again. The repetition a reader actually # notices is in the first breath. _OPENING_WORDS = 3 diff --git a/home.py b/home.py index a23fdbd..b3915d5 100644 --- a/home.py +++ b/home.py @@ -80,10 +80,6 @@ def home_token(phone: str) -> str: return token -def home_url(phone: str) -> str: - return f"{_APP_URL}/h/{home_token(phone)}" - - def rotate(phone: str) -> str: """Mint a new token, orphaning the old URL. The answer to a leaked link.""" old = get_profile(phone).get("home_token") diff --git a/page.py b/page.py index 18b3ebb..332a63d 100644 --- a/page.py +++ b/page.py @@ -14,7 +14,6 @@ import html import os -from datetime import datetime from urllib.parse import quote from timeutil import friendly_hhmm diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c4dd224 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.1.1 +ruff==0.15.4 diff --git a/requirements.txt b/requirements.txt index fae7e7f..d557b6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ -fastapi -uvicorn -twilio -anthropic -python-dotenv -python-multipart -tavily-python -psycopg2-binary -apscheduler -yfinance -Pillow>=10.0 +fastapi==0.139.2 +uvicorn==0.51.0 +twilio==9.10.9 +anthropic==0.117.0 +python-dotenv==1.2.2 +python-multipart==0.0.32 +tavily-python==0.7.27 +psycopg2-binary==2.9.12 +APScheduler==3.11.3 +yfinance==1.5.2 +Pillow==12.3.0 +requests==2.34.2 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..b4840f6 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,10 @@ +line-length = 100 +target-version = "py311" + +[lint] +select = ["E", "F", "W"] +ignore = [ + "E501", # long lines are mostly prose in docstrings and prompts + "E402", # load_dotenv() and init_db() deliberately run before later imports + "E702", # `conn = _conn(); cur = conn.cursor()` is the house style in db.py +] diff --git a/send_morning.py b/send_morning.py index 41072ee..881a2a7 100644 --- a/send_morning.py +++ b/send_morning.py @@ -1,5 +1,13 @@ +"""Trigger the morning job once. Respects each user's send window and the +per-day guard, so running it by hand cannot double-send.""" from dotenv import load_dotenv -load_dotenv() -from morning import send_morning_messages -send_morning_messages() + +def main() -> None: + load_dotenv() + from morning import send_morning_messages + send_morning_messages() + + +if __name__ == "__main__": + main() diff --git a/shopping.py b/shopping.py index aad0000..833c4c9 100644 --- a/shopping.py +++ b/shopping.py @@ -5,8 +5,9 @@ 2. Haiku picks the cheapest genuine match from the top candidates (guards against firing on unrelated cheap accessories/refurbs). 3. If no baseline yet, record it silently and move on. - 4. Otherwise alert if the target price is hit or the current price is at - least 15% below baseline. Cooldown gates repeat alerts. + 4. Otherwise alert if the target price is hit or the price has moved more + than MOVE_MIN_ABS in either direction since the baseline, then re-baseline. + Cooldown gates repeat alerts. Returns None on any API failure so the scheduler tick silently skips — never surfaces a "shopping tool failed" line to the user (same discipline @@ -384,8 +385,8 @@ def _draft_alert(product_name: str, current: dict, watch: dict, reason: str) -> def run_price_watches(): - """Scheduler job. Every 12h: check each active price watch, alert on target-hit - or >=15% drop from baseline. Silent-skip on any per-watch failure. Dispatches + """Scheduler job, twice daily on a cron (see main.py): check each active price + watch, alert on target-hit or any move over MOVE_MIN_ABS from baseline. Silent-skip on any per-watch failure. Dispatches to shopping (Google Shopping) or amazon (Amazon by ASIN) based on w['source'].""" from userprofile import _is_duplicate_subject from db import ( diff --git a/shows.py b/shows.py index d235cf8..89f052e 100644 --- a/shows.py +++ b/shows.py @@ -21,7 +21,7 @@ import threading import urllib.parse import urllib.request -from datetime import date, timedelta +from datetime import date TMDB_BASE = "https://api.themoviedb.org/3" @@ -106,12 +106,6 @@ def ambiguous_shows(found: list[dict]) -> list[dict]: return same if len(same) > 1 else [] -def resolve_show(name: str) -> dict | None: - """{"id", "name"} for a show title, or None. The single-answer form.""" - found = find_shows(name, limit=1) - return {"id": found[0]["id"], "name": found[0]["name"]} if found else None - - def next_episode(show_id: int, today: date | None = None) -> dict | None: """What this show is doing this week, or None if nothing is. diff --git a/test_add_price_watch_tool.py b/test_add_price_watch_tool.py index d653624..3489242 100644 --- a/test_add_price_watch_tool.py +++ b/test_add_price_watch_tool.py @@ -65,7 +65,8 @@ class TestTheWatchNamesWhatItMatched: stayed invisible until an alert arrived about the wrong product.""" def test_the_matched_title_is_in_the_tool_result(self): - import inspect, agent + import inspect + import agent block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ .split("elif b.name")[0] assert 'current.get("title")' in block @@ -74,7 +75,8 @@ def test_the_matched_title_is_in_the_tool_result(self): def test_the_model_is_told_to_say_it_out_loud(self): """A resolved thing named only in the tool result is still invisible to the person who can correct it.""" - import inspect, agent + import inspect + import agent block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ .split("elif b.name")[0] assert "correct you" in block diff --git a/test_calibration.py b/test_calibration.py index 86db45f..7b1aada 100644 --- a/test_calibration.py +++ b/test_calibration.py @@ -241,7 +241,7 @@ def test_the_prompt_names_every_tool_it_ships_with(self): def test_the_three_way_track_collision_is_resolved(self): block = agent.SYSTEM_PROMPT.split("USE THE RIGHT TOOL")[1] - line = next(l for l in block.split("\n") if l.startswith("- add_watch vs")) + line = next(ln for ln in block.split("\n") if ln.startswith("- add_watch vs")) for other in ("update_morning_briefing", "follow_team"): assert other in line diff --git a/test_clock_correctness.py b/test_clock_correctness.py index f23b4c1..63a10ab 100644 --- a/test_clock_correctness.py +++ b/test_clock_correctness.py @@ -7,7 +7,6 @@ from datetime import date, datetime, timezone from unittest.mock import patch -import pytest import datafeeds import timeutil diff --git a/test_commute.py b/test_commute.py index d38caae..eae714b 100644 --- a/test_commute.py +++ b/test_commute.py @@ -10,7 +10,7 @@ """ import inspect import re -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from unittest.mock import patch from zoneinfo import ZoneInfo diff --git a/test_guards_and_flights.py b/test_guards_and_flights.py index a52e125..1e6539f 100644 --- a/test_guards_and_flights.py +++ b/test_guards_and_flights.py @@ -136,7 +136,11 @@ def test_traffic_failure_asks_rather_than_redirects(self): assert not guards.redirects_elsewhere(out) def test_no_failure_string_names_a_competitor(self): - import flights, hotels, serpapi, shopping, traffic + import flights + import hotels + import serpapi + import shopping + import traffic # shopping gates on serpapi.API_KEY, not a local constant — patching the # wrong one lets the test out to the live network. with patch.object(flights, "SERP_API_KEY", ""), \ @@ -261,7 +265,8 @@ def test_the_job_never_raises(self): flightwatch.run_flight_watches() def test_it_is_scheduled_on_cron(self): - import inspect, main + import inspect + import main block = inspect.getsource(main).split("run_flight_watches,")[1][:120] assert '"cron"' in block and "misfire_grace_time" in block @@ -271,7 +276,8 @@ def test_an_overlap_is_reported_to_palmer_not_acted_on(self): """Semantic overlap has false positives — "NFL headlines" reads as a duplicate of "Philadelphia Eagles news" and is not — so silently dropping what someone asked for is the wrong failure.""" - import inspect, agent + import inspect + import agent src = inspect.getsource(agent.get_reply) block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] assert "topic_already_covered" in block @@ -279,7 +285,8 @@ def test_an_overlap_is_reported_to_palmer_not_acted_on(self): assert "Do not remove anything yourself" in block def test_the_page_will_not_render_one_article_twice(self): - import inspect, home + import inspect + import home src = inspect.getsource(home._fetch_headlines) assert "seen_urls" in src @@ -387,7 +394,11 @@ def test_empty_input_is_safe(self): def test_no_failure_string_in_the_codebase_trips_it(self): """The tool failure strings are what the model paraphrases. If one of them reads as a denial, the guard is policing a problem we wrote.""" - import flights, hotels, serpapi, shopping, traffic, datafeeds + import flights + import hotels + import serpapi + import shopping + import traffic with patch.object(flights, "SERP_API_KEY", ""), \ patch.object(hotels, "SERP_API_KEY", ""), \ patch.object(serpapi, "API_KEY", ""): diff --git a/test_home.py b/test_home.py index f76b405..23a93f9 100644 --- a/test_home.py +++ b/test_home.py @@ -293,7 +293,6 @@ class TestInvalidateExpiresPaidSections: city-derived section must not keep serving the old city's answer.""" def _invalidated(self, section): - now = time.time() pl = _payload() with patch.object(home, "get_profile", return_value={"home_token": "tok"}), \ patch.object(home, "load", return_value=pl), \ diff --git a/test_local_day_guards.py b/test_local_day_guards.py index 1437074..653c455 100644 --- a/test_local_day_guards.py +++ b/test_local_day_guards.py @@ -9,8 +9,6 @@ comparing today's morning line against ordinary chat rather than against yesterday's morning — which is the failure it was written for. """ -from datetime import datetime, timezone -from unittest.mock import patch import db import morning @@ -58,7 +56,7 @@ def test_the_repetition_guard_now_sees_yesterday(self, tmp_path, monkeypatch): class TestTheAlertCapsUseTheReadersDay: - """alerts.py was fixed for this and the two watch caps were not. + """The retired daily-alert job was fixed for this and the two watch caps were not. A cap keyed on the dyno's UTC date rolls at 17:00 Pacific — inside the evening, not between days — so a user could take the whole day's @@ -84,20 +82,23 @@ def test_a_price_watch_cap_reads_the_day_it_is_given(self): def test_the_watch_loop_derives_the_day_from_the_profile_it_already_reads(self): """One profile read per user already happens for the pacing cap; the local day rides along on it rather than costing another connection.""" - import inspect, watches + import inspect + import watches src = inspect.getsource(watches.run_watches) assert "local_today" in src assert "today=today" in src def test_the_price_loop_reads_every_profile_in_one_query(self): """Never `for phone in ...: get_profile(phone)` — that is N+1 a tick.""" - import inspect, shopping + import inspect + import shopping src = inspect.getsource(shopping.run_price_watches) assert "get_all_profiles" in src assert "get_profile(" not in src def test_the_write_side_takes_the_same_day_as_the_read(self): - import inspect, db + import inspect + import db for fn in (db.update_watch_alerted, db.update_price_watch_alerted): assert "today" in inspect.signature(fn).parameters @@ -109,7 +110,8 @@ class TestProfileFactsAgeOnTheReadersCalendar: the model as days_old: 1, and a volatile field was dropped a day early.""" def test_the_prompt_profile_is_aged_on_the_users_day(self): - import inspect, agent + import inspect + import agent src = inspect.getsource(agent._prompt_safe_profile) assert "local_today" in src assert "fresh_profile_for_prompt(profile)" not in src, \ diff --git a/test_morning_link.py b/test_morning_link.py index a427cd2..79711e3 100644 --- a/test_morning_link.py +++ b/test_morning_link.py @@ -198,7 +198,6 @@ def test_it_gives_up_after_one_redraft(self): assert out, "a second violation still ships rather than failing the morning" def test_ordinary_words_are_not_false_positives(self): - line = "Morning Jeff, the Cards page-turner of a ninth inning aside, cool and clear." out, calls = _draft_returning("Morning Jeff, cool and clear and 81.") assert len(calls) == 1 assert not morning._NAMES_THE_LINK.search("Cool and clear, pages of rain later") diff --git a/test_my_page_tool.py b/test_my_page_tool.py index d50ecac..14ff3f1 100644 --- a/test_my_page_tool.py +++ b/test_my_page_tool.py @@ -11,7 +11,6 @@ """ from unittest.mock import patch -import pytest import agent import prompts @@ -93,7 +92,7 @@ def test_it_returns_the_live_url(self): assert URL in result def test_it_refreshes_the_page_for_this_caller(self): - """ensure_fresh, not home_url — a link to a 404 or to yesterday's data + """ensure_fresh, never a bare URL builder — a link to a 404 or to yesterday's data is worse than no link.""" _, _, ensure = _drive() ensure.assert_called_once_with("+1555") @@ -275,7 +274,8 @@ def test_the_count_form_still_works(self): assert db.cancel_reminders(self.PHONE) == 3 def test_the_dispatch_names_them(self): - import inspect, agent + import inspect + import agent block = inspect.getsource(agent.get_reply).split('"cancel_reminders"')[1] \ .split("elif b.name")[0] assert "cancel_reminders_named" in block diff --git a/test_opening.py b/test_opening.py index 42c86b1..dc74b69 100644 --- a/test_opening.py +++ b/test_opening.py @@ -635,7 +635,8 @@ def test_the_prompt_routes_these_away_from_topics(self): def test_a_kinds_change_expires_the_cached_rows(self): """Otherwise they keep seeing the concerts they just asked to stop.""" - import inspect, agent + import inspect + import agent src = inspect.getsource(agent.get_reply) block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] assert "morning_prefs" in block and "opening" in block diff --git a/test_page.py b/test_page.py index f7d433d..2d29b15 100644 --- a/test_page.py +++ b/test_page.py @@ -100,18 +100,18 @@ def test_prompts_for_the_name(self): assert "doesn't know your name" in _render() def test_offers_a_prefilled_sms_link(self): - with patch.dict(os.environ, {"TWILIO_PHONE_NUMBER": "+17312525071"}): + with patch.dict(os.environ, {"TWILIO_PHONE_NUMBER": "+15550001234"}): html = _render() m = re.search(r'href="(sms:[^"]+)"', html) assert m, "expected a tappable sms: link" - assert "+17312525071" in m.group(1) + assert "+15550001234" in m.group(1) assert "body=My%20name%20is" in m.group(1), "the text should be pre-written" def test_the_prefilled_body_uses_percent_escapes_not_plusses(self): """The sms: scheme has no form encoding, so "+" is a literal plus. quote_plus put people into Messages with "My+name+is+" already typed, and that is exactly the text Palmer received back.""" - with patch.dict(os.environ, {"TWILIO_PHONE_NUMBER": "+17312525071"}): + with patch.dict(os.environ, {"TWILIO_PHONE_NUMBER": "+15550001234"}): html = _render() body = re.search(r'body=([^"]+)"', html).group(1) assert "+" not in body, f"literal plus in prefilled body: {body!r}" @@ -168,7 +168,8 @@ class TestSectionLabelsAreOneWord: """ def _labels(self) -> list[str]: - import inspect, re + import inspect + import re import page as page_mod # Labels are written straight into the markup, so read them from source # rather than rendering every possible payload permutation. diff --git a/test_phantom_history.py b/test_phantom_history.py index 12be5d3..4b1787d 100644 --- a/test_phantom_history.py +++ b/test_phantom_history.py @@ -101,6 +101,6 @@ def test_no_proactive_sender_calls_ensure_sms(self): "shopping.py", "flightwatch.py", "send_reminders.py"] for name in proactive: src = pathlib.Path(name).read_text() - calls = [l for l in src.splitlines() - if "ensure_sms(" in l and not l.strip().startswith("#")] + calls = [ln for ln in src.splitlines() + if "ensure_sms(" in ln and not ln.strip().startswith("#")] assert not calls, (name, calls) diff --git a/test_profile_freshness.py b/test_profile_freshness.py index 298f3b5..2aa7791 100644 --- a/test_profile_freshness.py +++ b/test_profile_freshness.py @@ -49,10 +49,10 @@ def test_past_its_life_it_disappears(self): def test_durable_facts_never_expire(self): """Name, city, job and relationships do not rot, and dating them would invite the model to doubt things it should not.""" - old = {"name": "Danny", "city": "Culver City", "job": "producer", + old = {"name": "Sam", "city": "Springfield", "job": "teacher", "field_dates": {"name": "2020-01-01"}} out = userprofile.fresh_profile_for_prompt(old) - assert out["name"] == "Danny" and out["city"] == "Culver City" + assert out["name"] == "Sam" and out["city"] == "Springfield" for f in ("name", "city", "job"): assert f not in userprofile.VOLATILE_FIELDS diff --git a/test_reminder_time.py b/test_reminder_time.py index 98f38b9..d078405 100644 --- a/test_reminder_time.py +++ b/test_reminder_time.py @@ -13,7 +13,6 @@ from datetime import datetime, timedelta, timezone, date from unittest.mock import patch -import pytest import agent import db @@ -177,7 +176,6 @@ class TestStoredRowsAreCanonical: def test_save_reminder_rewrites_the_offset(self, tmp_path, monkeypatch): _fresh(tmp_path, monkeypatch) db.save_reminder(PHONE, "call mom", "2026-08-31T09:00:00-05:00") - rows = db.get_pending_reminders(PHONE) if hasattr(db, "get_pending_reminders") else None conn = db._conn() cur = conn.cursor() cur.execute(f"SELECT due_at FROM reminders WHERE phone = {db.PH}", (PHONE,)) diff --git a/test_repetition.py b/test_repetition.py index e2d9fbe..8f7c3a4 100644 --- a/test_repetition.py +++ b/test_repetition.py @@ -9,8 +9,8 @@ days. Another got "Here you go - " three times, word for word. VARIATION — a scheduled message the user DID ask for, said the same way every -time: "Morning Drew - 103 today in Woodland Hills", "106 in Woodland Hills -today, Drew", "111 today in Woodland Hills, Drew". Suppressing those would be +time: "Morning Alex - 103 today in Cedar Falls", "106 in Cedar Falls +today, Alex", "111 today in Cedar Falls, Alex". Suppressing those would be wrong; they asked for a daily briefing. Only the phrasing may not repeat. And token overlap cannot see it — those score 0.23 against each other. @@ -26,10 +26,10 @@ REPEAT_MORNINGS = [ - "Morning Drew - 103 today in Woodland Hills, stay inside if you can.", - "106 in Woodland Hills today, Drew - hottest it's been all week.", - "111 today in Woodland Hills, Drew - and there's actually a chance of thunderstorms.", - "110 in Woodland Hills today, so outdoor plans need a rethink.", + "Morning Alex - 103 today in Cedar Falls, stay inside if you can.", + "106 in Cedar Falls today, Alex - hottest it's been all week.", + "111 today in Cedar Falls, Alex - and there's actually a chance of thunderstorms.", + "110 in Cedar Falls today, so outdoor plans need a rethink.", ] FRESH_LINES = [ "Courtney Barnett plays the Hollywood Palladium Friday if you want a reason to get out.", @@ -95,7 +95,7 @@ def test_a_genuinely_different_opening_is_left_alone(self): def test_leading_with_something_else_clears_it(self): """The redraft asks for the same facts starting somewhere else.""" - recast = "Courtney Barnett's at the Hollywood Palladium Friday, and it's 110 in Woodland Hills." + recast = "Courtney Barnett's at the Hollywood Palladium Friday, and it's 110 in Cedar Falls." assert guards.repeats_opening(recast, REPEAT_MORNINGS) is None def test_the_morning_line_redrafts_on_a_repeat(self): diff --git a/test_shows.py b/test_shows.py index 0845dc1..8077375 100644 --- a/test_shows.py +++ b/test_shows.py @@ -276,11 +276,6 @@ def test_following_expires_the_cached_section(self): block = src.split('"follow_show"')[1].split("elif b.name")[0] assert "invalidate" in block - def test_resolve_returns_none_rather_than_a_wrong_show(self): - with patch.object(shows, "_key", return_value="k"), \ - patch.object(shows, "_get", return_value={"results": []}): - assert shows.resolve_show("asdfqwer") is None - class TestAnAmbiguousTitleIsAskedAboutNotGuessed: """sports.find_teams returns a LIST because "Cardinals" is two teams, and @@ -308,15 +303,9 @@ def test_one_match_is_not(self): def test_the_description_separates_them_for_a_person(self): assert shows.describe_show(self.GB) == "The Office (2001, GB)" - def test_resolve_show_still_answers_with_one(self): - with patch.object(shows, "_get", return_value={"results": [ - {"id": 1, "name": "Reacher", "first_air_date": "2022-02-04", - "origin_country": ["US"]}]}), \ - patch.object(shows, "_key", return_value="k"): - assert shows.resolve_show("Reacher") == {"id": 1, "name": "Reacher"} - def test_the_dispatch_refuses_to_pick(self): - import inspect, agent + import inspect + import agent block = inspect.getsource(agent.get_reply).split('"follow_show"')[1] \ .split("elif b.name")[0] assert "ambiguous_shows" in block diff --git a/test_sports.py b/test_sports.py index 819332d..56b9211 100644 --- a/test_sports.py +++ b/test_sports.py @@ -337,7 +337,7 @@ def _run(game, prev=None, alert_count=0, delivered=True, live="key"): patch.object(scorewatch, "get_game_alert", return_value=prev), \ patch.object(scorewatch, "_draft", return_value="line"), \ patch.object(scorewatch, "record_game_alert", - side_effect=lambda p, g, h, a, l, st, sent: rec["saved"].append((h, a, sent))), \ + side_effect=lambda p, g, h, a, lv, st, sent: rec["saved"].append((h, a, sent))), \ patch("sms_util.send_sms", side_effect=lambda p, t, **k: rec["sms"].append(t) or delivered): scorewatch.run_score_alerts() yield rec diff --git a/test_watches_story.py b/test_watches_story.py index 81d05ea..7d60372 100644 --- a/test_watches_story.py +++ b/test_watches_story.py @@ -145,7 +145,7 @@ class TestTheWatchAlertHasAVoice: """This was the one user-facing message in the system with no Palmer in it: a bare `title\\nurl`, no system prompt, no calibration — against the rule that anything the user reads is drafted through _build_system. Its - sibling alerts.py has always done this; the two paths simply diverged.""" + sibling, the retired daily-alert job, always did this; the two paths simply diverged.""" TOP = {"title": "Eagles sign Hurts to an extension", "url": "https://example.com/a", diff --git a/test_weather_locations.py b/test_weather_locations.py index cc0fef1..a305d22 100644 --- a/test_weather_locations.py +++ b/test_weather_locations.py @@ -202,7 +202,8 @@ def test_the_read_path_still_takes_the_top_hit(self): assert (lat, lon) == (39.8, -89.6) def test_the_dispatch_refuses_to_pick(self): - import inspect, agent + import inspect + import agent block = inspect.getsource(agent.get_reply).split('"add_weather_location"')[1] \ .split("elif b.name")[0] assert "ambiguous_location" in block diff --git a/userprofile.py b/userprofile.py index c068d12..16eb0f2 100644 --- a/userprofile.py +++ b/userprofile.py @@ -4,7 +4,6 @@ stop unprompted messages repeating a subject. """ import json -import re from db import ( get_profile, upsert_profile, get_message_count, get_older_messages, HISTORY_LIMIT, diff --git a/watches.py b/watches.py index 0d7d3bd..cad0a32 100644 --- a/watches.py +++ b/watches.py @@ -21,8 +21,8 @@ def _daily_ok(watch: dict, cap: int = DAILY_ALERT_MAX, today: str | None = None) It keyed on the dyno's UTC date, which rolls at 17:00 Pacific — mid-evening — so the allowance reset in the middle of a user's evening and could be - spent again before their own day was over. alerts.py had the same defect - and was fixed; this is the same fix, and the write side (db's + spent again before their own day was over. the retired daily-alert job had the same + defect and was fixed; this is the same fix, and the write side (db's update_watch_alerted) takes the same date so the two agree. `cap` is lowered for users whose reactions say Palmer is texting too much — @@ -195,8 +195,8 @@ def _draft_alert(phone: str, watch: dict, top: dict, fallback: str) -> str: This was the one user-facing message in the system with no Palmer in it — a bare `title\nurl`, no system prompt, no calibration, against the rule - that anything the user reads is drafted through _build_system. Its sibling - alerts.py has always done this; the two paths simply diverged. + that anything the user reads is drafted through _build_system. Its sibling, + the retired daily-alert job, always did this; the two paths simply diverged. Never raises. Every failure falls back to the raw headline, which is what production sent before this existed — the same discipline as diff --git a/weather.py b/weather.py index 8dbf7bb..372c868 100644 --- a/weather.py +++ b/weather.py @@ -1,5 +1,5 @@ """Weather: geocoding, NWS (US) and Open-Meteo (everywhere else).""" -import re +import os from datetime import datetime, timedelta, timezone, date as _date from netutil import _http_get_json_retry @@ -19,7 +19,9 @@ 95: "thunderstorm", 96: "thunderstorm with hail", 99: "severe thunderstorm", } -_NWS_USER_AGENT = "PalmerSMS/1.0 (contact: jeffreyblarson00@gmail.com)" +# NWS requires a contact in the User-Agent; it is read from the environment so +# nobody's personal address lives in the source. +_NWS_USER_AGENT = f"PalmerSMS/1.0 (contact: {os.environ.get('PALMER_CONTACT_EMAIL', 'ops@palmer.invalid')})" def _is_us_coords(lat: float, lon: float) -> bool: """Rough bounding boxes for NWS-covered territory: CONUS, AK, HI, PR/USVI.""" @@ -103,7 +105,7 @@ def geocode_candidates(location: str) -> list[str]: """Other real places sharing this name, best match first, or []. Write paths only — this runs when a user PINS a location, never on the - read path that fires on every page view. Same terms as resolve_show and + read path that fires on every page view. Same terms as find_shows and _normalize_price_topic.""" key = (location or "").strip().lower() if key not in _geocode_alts: @@ -130,7 +132,7 @@ def resolve_weather_location(location: str) -> str | None: same display form _geocode/weather_snapshot use ("City, State"). Runs on the WRITE path when a user adds a location — never on read, which happens on every page view — same terms as tickers.resolve_company_ticker and - shows.resolve_show. None means the model should ask rather than guess.""" + shows.find_shows. None means the model should ask rather than guess.""" try: _, _, resolved = _geocode(location) return resolved diff --git a/wxaudit.py b/wxaudit.py index ece9587..7273979 100644 --- a/wxaudit.py +++ b/wxaudit.py @@ -186,7 +186,7 @@ def _clear_best_cache() -> None: def report(days: int = 30) -> str: - """Human-readable scoreboard. `python -c "import wxaudit; print(wxaudit.report())"`""" + """Human-readable scoreboard. Run with `python wxaudit.py`.""" from db import forecast_scores rows = forecast_scores(days) if not rows: @@ -199,3 +199,7 @@ def report(days: int = 30) -> str: out.append("\nbias: + means the source forecasts hotter than reality. " "Lowest mae per city wins.") return "\n".join(out) + + +if __name__ == "__main__": + print(report())