From a4d9be3ee941fc962e74dddd75553b911ca819e5 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 10 Jul 2026 19:31:41 -0400 Subject: [PATCH 1/5] Turn strava.py chat into a tool-using agent with runlog search Replace the passive single-shot chat with an agent loop: the conversation now runs llm's tool-calling loop (model.conversation(tools=[...]) + conversation.chain()), so the model can decide mid-conversation to call a tool and act on the result, instead of only answering from the seeded activity JSON. The agent's first tool is search_runlog, a keyword search over the athlete's running log (RUNLOG.txt, override with --runlog). It lives in a new self-contained strava_tools.py built from pure parse_runlog / search_entries / format_entries helpers, which are the test targets in test_strava_tools.py. Adding a second tool is a one-line change to the tools=[...] list. format_entries returns self-contained titles + bodies on purpose: with the default OpenAI (Chat Completions) plugin, reasoning state is not preserved across turns or across a tool hop, so the tool's returned text is the only durable state the next turn sees. strava.py carries a comment on this and on the OpenAI Agents SDK as a future extension path. Also updates SYSTEM_PROMPT to tell the model about the tool and to ground history claims in what it returns, and documents the agent in CLAUDE.md. --- strava.py | 52 ++++++++++++++++++++---- strava_prompts.py | 6 +++ strava_tools.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ test_strava_tools.py | 74 +++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 strava_tools.py create mode 100644 test_strava_tools.py diff --git a/strava.py b/strava.py index 57dbc22..28e257e 100644 --- a/strava.py +++ b/strava.py @@ -22,7 +22,9 @@ from dotenv import load_dotenv from stravalib import Client +import strava_tools from strava_prompts import SYSTEM_PROMPT +from strava_tools import search_runlog TOKEN_PATH = Path.home() / ".strava_tokens.json" @@ -135,11 +137,21 @@ def main(): default=None, help="llm model to use (default: your configured llm default model)", ) + parser.add_argument( + "--runlog", + default=None, + help="Path to the running log the search_runlog tool reads (default: RUNLOG.txt)", + ) args = parser.parse_args() if args.end and not args.start: parser.error("--end requires --start") + # Point the search_runlog tool at a custom log file if requested (default lives in + # strava_tools.RUNLOG_PATH). Set before the tool is ever invoked in the chat loop below. + if args.runlog: + strava_tools.RUNLOG_PATH = Path(args.runlog) + # Load STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and OPENAI_API_KEY from a local .env # (gitignored; see .env.example) so they don't have to be exported in the shell. # @@ -194,8 +206,31 @@ def main(): # so this script relies on whatever model/key the user has set up via `llm`. # - model.conversation() is a multi-turn object that retains history automatically, so # follow-up questions keep the context of earlier turns (and the activity below). + # + # Future extensions — staying on `llm` vs. an agent SDK: + # This tool uses `llm` for the chat, and for tools it uses `llm`'s tool-calling loop + # (`model.conversation(tools=[...])` then `conversation.chain(prompt)`, which auto-executes + # tool calls and re-prompts until the model answers). Tools are plain Python functions whose + # docstring becomes the schema — e.g. a runlog search in strava_tools.py. `llm` keeps this a + # self-contained, minimal-dependency script and stays provider-agnostic. + # If this ever grows into a richer agent — several tools, input/output guardrails, evals, + # persistent memory, or multi-agent handoffs — the OpenAI Agents SDK + # (https://developers.openai.com/api/docs/guides/agents) provides those as first-class + # primitives, at the cost of a heavier dependency and breaking the repo's one-file-per-tool + # convention. Prefer `llm` while the tool surface stays small. + # + # Reasoning state does NOT carry across turns here. `llm`'s default OpenAI plugin talks to + # the Chat Completions API, which never returns a reasoning model's internal reasoning + # tokens, and a conversation replays only the visible transcript (prompts, response text, + # and — inside a chain — tool calls and their results). So on each turn, and on each hop of + # a chain() tool loop, the model re-derives its reasoning from that transcript rather than + # resuming an earlier train of thought. Treat the returned text of search_runlog as the + # only durable state (hence format_entries returns self-contained titles + bodies). + # Preserving reasoning across turns would require the Responses API (via the separate + # llm-openai-plugin, `-m openai/...`) AND that plugin threading previous_response_id or + # encrypted reasoning content through conversation turns — not something wired up here. model = llm.get_model(args.model) - conversation = model.conversation() + conversation = model.conversation(tools=[search_runlog]) # Seed the activity into context via the system prompt so it's available without a # round-trip — nothing is sent until the user asks a question. @@ -218,19 +253,20 @@ def main(): if user_input.lower() in ("exit", "quit"): break - # conversation.prompt() sends the turn to the model; the system prompt (carrying the - # activity) is passed only on the first turn, since the conversation retains history. + # conversation.chain() sends the turn and, unlike prompt(), auto-executes any + # search_runlog tool calls the model makes and re-prompts until it produces a final + # answer. The system prompt (carrying the activity) is passed only on the first turn, + # since the conversation retains history. if first_turn: - response = conversation.prompt(user_input, system=system) + response = conversation.chain(user_input, system=system) else: - response = conversation.prompt(user_input) + response = conversation.chain(user_input) first_turn = False - # response.text() resolves and returns the full reply as a single string. - # Alternative: an llm response is iterable, so you can stream each text chunk as it - # arrives with `for chunk in response: print(chunk, end="", flush=True)`. + # response.text() resolves the whole chain (including tool round-trips) and returns the + # final reply as a single string. print(response.text()) diff --git a/strava_prompts.py b/strava_prompts.py index 0e3ae04..2b13caf 100644 --- a/strava_prompts.py +++ b/strava_prompts.py @@ -12,4 +12,10 @@ Convert units to whatever is natural for the user (km or miles, min/km or min/mile) and show your reasoning briefly. Be concise and specific, cite the numbers you used, and say so plainly when the data doesn't support a conclusion. + +You also have a search_runlog tool over the athlete's past running log (prior runs and +coaching notes). Call it when history, trends, or earlier guidance would strengthen an +answer -- for example when the user asks how this run compares to past efforts, or when +recalling advice you gave before. Ground any claim about the athlete's history in what the +search returns; if it returns nothing relevant, say so rather than inventing past runs. """ diff --git a/strava_tools.py b/strava_tools.py new file mode 100644 index 0000000..464ba05 --- /dev/null +++ b/strava_tools.py @@ -0,0 +1,97 @@ +# Tools for the strava.py chat agent, exposed to the LLM via `llm`'s tool-calling +# (model.conversation(tools=[...]) + conversation.chain(...)). `llm` builds each tool's +# schema from the function signature and uses its docstring as the description shown to the +# model, so search_runlog's docstring is written for the model to read. +# +# Structure mirrors the repo's fetch_*/process_*/format_* seam: the pure helpers +# (parse_runlog, search_entries, format_entries) do no I/O and are the test targets; +# search_runlog is the thin tool wrapper that reads the file and glues them together. + +from pathlib import Path +from typing import NamedTuple + +# Default runlog location (the RUNLOG.txt next to this script). strava.py can override this +# module attribute from its --runlog flag, and tests point it at a fixture file. +RUNLOG_PATH = Path(__file__).parent / "RUNLOG.txt" + + +class RunlogEntry(NamedTuple): + title: str + body: str + + +def parse_runlog(text: str) -> list[RunlogEntry]: + """Split raw runlog text into entries. + + An entry starts at a "title" line -- a non-blank line that is not a bullet ("- ...") -- + and runs until the next title line. Blank and bullet lines in between form the body. + This tolerates the title / blank-line / bullets layout in RUNLOG.txt without needing a + strict blank-line delimiter between entries. + """ + entries: list[RunlogEntry] = [] + title: str | None = None + body_lines: list[str] = [] + + def flush() -> None: + if title is not None: + entries.append(RunlogEntry(title=title, body="\n".join(body_lines).strip())) + + for raw in text.splitlines(): + line = raw.rstrip() + is_title = bool(line.strip()) and not line.lstrip().startswith("-") + if is_title: + flush() + title = line.strip() + body_lines = [] + elif title is not None: + body_lines.append(line) + flush() + return entries + + +def _tokenize(s: str) -> list[str]: + """Lowercase alphanumeric word tokens, so 'Half-Marathon!' -> ['half', 'marathon'].""" + return ["".join(c for c in tok if c.isalnum()) for tok in s.lower().split() if tok] + + +def search_entries( + entries: list[RunlogEntry], query: str, limit: int = 3 +) -> list[RunlogEntry]: + """Rank entries by case-insensitive query-term matches and return the top `limit`. + + A term found in the title counts double a term found in the body. Entries with no + matching term are dropped; ties keep the original (chronological) order. + """ + terms = set(_tokenize(query)) + if not terms: + return [] + + scored: list[tuple[int, int, RunlogEntry]] = [] + for i, entry in enumerate(entries): + title_tokens = set(_tokenize(entry.title)) + body_tokens = set(_tokenize(entry.body)) + score = 2 * len(terms & title_tokens) + len(terms & body_tokens) + if score > 0: + # Negate index so a stable sort keeps earlier entries first within a score tier. + scored.append((score, -i, entry)) + + scored.sort(reverse=True) + return [entry for _, _, entry in scored[:limit]] + + +def format_entries(entries: list[RunlogEntry]) -> str: + """Render matched entries back to plain text for the model (self-contained: the returned + string is the only thing that survives into the next turn, so include titles + bodies).""" + if not entries: + return "No matching runlog entries found." + return "\n\n".join(f"{e.title}\n{e.body}".rstrip() for e in entries) + + +def search_runlog(query: str) -> str: + """Search the athlete's past running log for entries relevant to `query` + (for example: 'half marathon pacing', 'late-run fade', 'hill workouts'). + Returns the most relevant past run notes and coaching advice as text. Call this + to compare the current activity against past runs or to recall prior guidance, + and ground any claims about the athlete's history in what it returns.""" + text = Path(RUNLOG_PATH).read_text() + return format_entries(search_entries(parse_runlog(text), query)) diff --git a/test_strava_tools.py b/test_strava_tools.py new file mode 100644 index 0000000..53af646 --- /dev/null +++ b/test_strava_tools.py @@ -0,0 +1,74 @@ +import strava_tools +from strava_tools import ( + RunlogEntry, + format_entries, + parse_runlog, + search_entries, + search_runlog, +) + +# A small multi-entry fixture in the RUNLOG.txt shape: a title line, a blank line, bullets. +RUNLOG = """April 26 Half Marathon Run + +- The fade in watts alongside the pace drop suggests your legs gave out. Add a weekly + long run finishing at goal pace to build muscular endurance. + +May 10 Hill Repeats + +- Strong power on the climbs; turnover held late. Hills are paying off. + +May 17 Easy Recovery Run + +- Nice and controlled, heart rate stayed in zone 2 the whole way. +""" + + +def test_parse_runlog_splits_entries(): + entries = parse_runlog(RUNLOG) + assert [e.title for e in entries] == [ + "April 26 Half Marathon Run", + "May 10 Hill Repeats", + "May 17 Easy Recovery Run", + ] + assert entries[0].body.startswith("- The fade in watts") + + +def test_parse_runlog_single_entry_and_trailing_blanks(): + entries = parse_runlog("Solo Run\n\n- Felt good.\n\n\n") + assert len(entries) == 1 + assert entries[0] == RunlogEntry(title="Solo Run", body="- Felt good.") + + +def test_search_entries_finds_match(): + entries = parse_runlog(RUNLOG) + results = search_entries(entries, "hill repeats") + assert results[0].title == "May 10 Hill Repeats" + + +def test_search_entries_ranks_title_match_above_body_only(): + entries = parse_runlog(RUNLOG) + # "hill" is in the Hill Repeats title and also in the half-marathon body ("Hills"? no) -- + # use a term that appears in one title and one body to check title weighting. + results = search_entries(entries, "half", limit=3) + assert results[0].title == "April 26 Half Marathon Run" + + +def test_search_entries_respects_limit_and_no_match(): + entries = parse_runlog(RUNLOG) + assert search_entries(entries, "run", limit=1).__len__() == 1 + assert search_entries(entries, "cycling swimming triathlon") == [] + + +def test_format_entries_no_match_sentinel(): + assert format_entries([]) == "No matching runlog entries found." + + +def test_search_runlog_end_to_end(tmp_path, monkeypatch): + runlog = tmp_path / "RUNLOG.txt" + runlog.write_text(RUNLOG) + monkeypatch.setattr(strava_tools, "RUNLOG_PATH", runlog) + + out = search_runlog("hill climbs turnover") + assert "May 10 Hill Repeats" in out + + assert search_runlog("kayaking") == "No matching runlog entries found." From 6fce8a4936d3c5cde738b9cb052d6d440eb60499 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 14 Jul 2026 15:46:14 -0400 Subject: [PATCH 2/5] Rank runlog search by TF-IDF instead of term counting Replace search_entries' hand-rolled token-match scorer with scikit-learn's TfidfVectorizer (fit corpus, cosine-score the query, stable-sort, keep score > 0, slice to limit). TF-IDF weights terms by rarity, so a match on a distinctive word (e.g. "fartlek") outranks one on a ubiquitous word like "run" -- the thing plain counting got wrong. Drops the now-unused _tokenize. Adds scikit-learn to strava.py's script block and pyproject.toml (standalone run and pytest are separate environments). Comments record the decisions (scikit-learn for the simplest code despite the numpy/scipy weight; title text folded into the document but not up-weighted) and point to BM25 as the next step, via rank-bm25 or SQLite FTS5. Tests now cover only the logic we wrote around the vectorizer -- the empty-corpus guard, the score > 0 filter, and the limit slice -- not scikit-learn's ranking; drops the ranking and end-to-end glue tests. --- CLAUDE.md | 41 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + strava.py | 1 + strava_tools.py | 59 +++++++++++++++++++++++++++----------------- test_strava_tools.py | 41 +++++++++++------------------- 5 files changed, 94 insertions(+), 49 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c9e301 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +Run all tests: +```sh +uv run pytest +``` + +Run a single test file: +```sh +uv run pytest test_septa.py +``` + +Run a single test by name: +```sh +uv run pytest test_septa.py::test_process_response_extracts_trains +``` + +Run a script directly (dependencies auto-managed by uv): +```sh +uv run septa.py "Suburban Station" +``` + +## Architecture + +Each utility is a self-contained script with inline `uv` dependency declarations at the top (`# /// script ... ///`). There is no shared library — scripts are independent and can be copied or run standalone. + +**Script structure pattern** (follow this for new tools): +- `fetch_*`: hits an external API, returns raw data +- `process_*`: transforms raw data into domain objects/lists — this is what tests target +- `format_*` / `display_*`: formats for terminal output +- `main()`: wires args → fetch → process → display + +Tests live in `test_.py` alongside the script. Tests import directly from the script module (e.g. `from septa import process_response`). Tests mock at the network boundary (`requests.post`, `requests.get`) and test `process_*` functions with real data fixtures, not mocked internal calls. + +**strava.py** fetches a Strava activity and starts an interactive terminal chat about it, using the `llm` Python API for a multi-turn conversation with the activity held in context. It uses the `stravalib` library and requires OAuth2 with token refresh, and relies on `llm`'s configured default model (override with `-m/--model`). Credentials (`STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET`, and `OPENAI_API_KEY` for `llm`) load at startup from a gitignored `.env` via `python-dotenv` (see `.env.example`), falling back to real environment variables, which take precedence. `strava_prompts.py` holds the `SYSTEM_PROMPT` for the chat analysis. `test_strava.py` holds tests (currently token/URL helpers). + +The chat is a small agent: it runs `llm`'s tool-calling loop (`model.conversation(tools=[...])` + `conversation.chain(...)`, which auto-executes tool calls and re-prompts until the model answers). `strava_tools.py` provides the `search_runlog` tool — a TF-IDF-ranked search (scikit-learn `TfidfVectorizer`) over the athlete's running log (`RUNLOG.txt`, override with `--runlog`) — built from pure `parse_runlog`/`search_entries`/`format_entries` helpers. Tests in `test_strava_tools.py` cover only the logic we wrote (parsing, the guard/`>0` filter/limit around the vectorizer, the no-match sentinel), not scikit-learn's ranking. BM25 is documented in-code as the next step. Note: with the default OpenAI plugin (Chat Completions), reasoning-model reasoning state is not preserved across turns — the model re-reasons each turn from the visible transcript, so tool results must be self-contained. diff --git a/pyproject.toml b/pyproject.toml index 58fd7a7..406a50c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "requests>=2.32.5", + "scikit-learn", ] [project.optional-dependencies] diff --git a/strava.py b/strava.py index 28e257e..19c834b 100644 --- a/strava.py +++ b/strava.py @@ -5,6 +5,7 @@ # "stravalib==2.4", # "llm==0.31", # "python-dotenv==1.0.1", +# "scikit-learn", # ] # /// diff --git a/strava_tools.py b/strava_tools.py index 464ba05..2f505a3 100644 --- a/strava_tools.py +++ b/strava_tools.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import NamedTuple +from sklearn.feature_extraction.text import TfidfVectorizer + # Default runlog location (the RUNLOG.txt next to this script). strava.py can override this # module attribute from its --runlog flag, and tests point it at a fixture file. RUNLOG_PATH = Path(__file__).parent / "RUNLOG.txt" @@ -49,34 +51,47 @@ def flush() -> None: return entries -def _tokenize(s: str) -> list[str]: - """Lowercase alphanumeric word tokens, so 'Half-Marathon!' -> ['half', 'marathon'].""" - return ["".join(c for c in tok if c.isalnum()) for tok in s.lower().split() if tok] - - def search_entries( entries: list[RunlogEntry], query: str, limit: int = 3 ) -> list[RunlogEntry]: - """Rank entries by case-insensitive query-term matches and return the top `limit`. + """Rank entries by TF-IDF cosine similarity to the query and return the top `limit`. - A term found in the title counts double a term found in the body. Entries with no - matching term are dropped; ties keep the original (chronological) order. + TF-IDF weights each term by how rare it is across the log, so a match on a distinctive + word (e.g. 'fartlek') outranks a match on a ubiquitous one (e.g. 'run') -- the thing + plain term-counting got wrong. Entries sharing no terms with the query are dropped; + ties keep the log's original (chronological) order. """ - terms = set(_tokenize(query)) - if not terms: + # Decision: scikit-learn's TfidfVectorizer instead of a hand-rolled scorer. It gives the + # simplest correct code -- it tokenizes, lowercases, and L2-normalizes for us -- at the + # cost of a heavy numpy/scipy dependency, declared in BOTH strava.py's script block and + # pyproject.toml because standalone `uv run strava.py` and `uv run pytest` are different + # environments. We chose the smaller function over the lighter dependency on purpose. + # + # Decision: title text is folded into the document but NOT up-weighted. Runlog titles are + # just a date + run type, so a title-only boost wasn't worth the extra code; if titles + # ever need to rank higher, double them here (f"{e.title} {e.title}\n{e.body}"). + # + # BM25 is the natural next step if ranking needs to improve: it keeps this TF-IDF backbone + # but adds term-frequency saturation (repeated terms give diminishing returns) and + # document-length normalization (long entries don't win just by being long). To switch, + # replace only the vectorize-and-score lines below -- keeping the guard, the `> 0` filter, + # and the sort/limit tail -- with either rank-bm25 + # (BM25Okapi(tokenized_docs).get_scores(query.split())) or, for a zero-dependency route + # more in keeping with this repo, SQLite FTS5's built-in bm25(). + if not entries: + # TfidfVectorizer().fit_transform([]) raises "empty vocabulary"; guard it. return [] - - scored: list[tuple[int, int, RunlogEntry]] = [] - for i, entry in enumerate(entries): - title_tokens = set(_tokenize(entry.title)) - body_tokens = set(_tokenize(entry.body)) - score = 2 * len(terms & title_tokens) + len(terms & body_tokens) - if score > 0: - # Negate index so a stable sort keeps earlier entries first within a score tier. - scored.append((score, -i, entry)) - - scored.sort(reverse=True) - return [entry for _, _, entry in scored[:limit]] + docs = [f"{e.title}\n{e.body}" for e in entries] + vectorizer = TfidfVectorizer() + matrix = vectorizer.fit_transform(docs) + # TF-IDF rows are L2-normalized, so this dot product is exactly the cosine similarity. + # An empty or all-unknown query yields a zero vector, hence all-zero scores -> no matches. + scores = (vectorizer.transform([query]) @ matrix.T).toarray().ravel() + # (-scores).argsort is descending; kind="stable" makes earlier entries win ties. + order = (-scores).argsort(kind="stable") + # The `> 0` filter is load-bearing: it makes a no-match query return [] (and thus + # format_entries' "no matching entries" sentinel) rather than unrelated entries. + return [entries[i] for i in order if scores[i] > 0][:limit] def format_entries(entries: list[RunlogEntry]) -> str: diff --git a/test_strava_tools.py b/test_strava_tools.py index 53af646..14f9339 100644 --- a/test_strava_tools.py +++ b/test_strava_tools.py @@ -1,10 +1,8 @@ -import strava_tools from strava_tools import ( RunlogEntry, format_entries, parse_runlog, search_entries, - search_runlog, ) # A small multi-entry fixture in the RUNLOG.txt shape: a title line, a blank line, bullets. @@ -39,36 +37,25 @@ def test_parse_runlog_single_entry_and_trailing_blanks(): assert entries[0] == RunlogEntry(title="Solo Run", body="- Felt good.") -def test_search_entries_finds_match(): - entries = parse_runlog(RUNLOG) - results = search_entries(entries, "hill repeats") - assert results[0].title == "May 10 Hill Repeats" +# The search_entries tests cover only the wrapping logic we wrote around TfidfVectorizer -- +# the empty-corpus guard, the score > 0 filter (which drives the no-match sentinel), and the +# limit slice. They deliberately do NOT assert ranking order: that's scikit-learn's TF-IDF +# behavior, which we don't own and shouldn't re-test. -def test_search_entries_ranks_title_match_above_body_only(): - entries = parse_runlog(RUNLOG) - # "hill" is in the Hill Repeats title and also in the half-marathon body ("Hills"? no) -- - # use a term that appears in one title and one body to check title weighting. - results = search_entries(entries, "half", limit=3) - assert results[0].title == "April 26 Half Marathon Run" +def test_search_entries_empty_corpus(): + assert search_entries([], "anything") == [] -def test_search_entries_respects_limit_and_no_match(): - entries = parse_runlog(RUNLOG) - assert search_entries(entries, "run", limit=1).__len__() == 1 - assert search_entries(entries, "cycling swimming triathlon") == [] +def test_search_entries_no_shared_terms_returns_empty(): + entries = [RunlogEntry("A", "hill repeats"), RunlogEntry("B", "recovery jog")] + assert search_entries(entries, "cycling swimming") == [] -def test_format_entries_no_match_sentinel(): - assert format_entries([]) == "No matching runlog entries found." +def test_search_entries_respects_limit(): + entries = [RunlogEntry(t, "tempo run") for t in ("A", "B", "C")] + assert len(search_entries(entries, "tempo", limit=2)) == 2 -def test_search_runlog_end_to_end(tmp_path, monkeypatch): - runlog = tmp_path / "RUNLOG.txt" - runlog.write_text(RUNLOG) - monkeypatch.setattr(strava_tools, "RUNLOG_PATH", runlog) - - out = search_runlog("hill climbs turnover") - assert "May 10 Hill Repeats" in out - - assert search_runlog("kayaking") == "No matching runlog entries found." +def test_format_entries_no_match_sentinel(): + assert format_entries([]) == "No matching runlog entries found." From 2cfc63d775dd75da2c0b1bfbd83d2d6caeee6a85 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 17 Jul 2026 17:57:03 -0400 Subject: [PATCH 3/5] Stop tracking CLAUDE.md --- CLAUDE.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7c9e301..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,41 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -Run all tests: -```sh -uv run pytest -``` - -Run a single test file: -```sh -uv run pytest test_septa.py -``` - -Run a single test by name: -```sh -uv run pytest test_septa.py::test_process_response_extracts_trains -``` - -Run a script directly (dependencies auto-managed by uv): -```sh -uv run septa.py "Suburban Station" -``` - -## Architecture - -Each utility is a self-contained script with inline `uv` dependency declarations at the top (`# /// script ... ///`). There is no shared library — scripts are independent and can be copied or run standalone. - -**Script structure pattern** (follow this for new tools): -- `fetch_*`: hits an external API, returns raw data -- `process_*`: transforms raw data into domain objects/lists — this is what tests target -- `format_*` / `display_*`: formats for terminal output -- `main()`: wires args → fetch → process → display - -Tests live in `test_.py` alongside the script. Tests import directly from the script module (e.g. `from septa import process_response`). Tests mock at the network boundary (`requests.post`, `requests.get`) and test `process_*` functions with real data fixtures, not mocked internal calls. - -**strava.py** fetches a Strava activity and starts an interactive terminal chat about it, using the `llm` Python API for a multi-turn conversation with the activity held in context. It uses the `stravalib` library and requires OAuth2 with token refresh, and relies on `llm`'s configured default model (override with `-m/--model`). Credentials (`STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET`, and `OPENAI_API_KEY` for `llm`) load at startup from a gitignored `.env` via `python-dotenv` (see `.env.example`), falling back to real environment variables, which take precedence. `strava_prompts.py` holds the `SYSTEM_PROMPT` for the chat analysis. `test_strava.py` holds tests (currently token/URL helpers). - -The chat is a small agent: it runs `llm`'s tool-calling loop (`model.conversation(tools=[...])` + `conversation.chain(...)`, which auto-executes tool calls and re-prompts until the model answers). `strava_tools.py` provides the `search_runlog` tool — a TF-IDF-ranked search (scikit-learn `TfidfVectorizer`) over the athlete's running log (`RUNLOG.txt`, override with `--runlog`) — built from pure `parse_runlog`/`search_entries`/`format_entries` helpers. Tests in `test_strava_tools.py` cover only the logic we wrote (parsing, the guard/`>0` filter/limit around the vectorizer, the no-match sentinel), not scikit-learn's ranking. BM25 is documented in-code as the next step. Note: with the default OpenAI plugin (Chat Completions), reasoning-model reasoning state is not preserved across turns — the model re-reasons each turn from the visible transcript, so tool results must be self-contained. From d7ff02ee966355761fe21503411dbab44bd4d0f4 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 21 Jul 2026 15:35:46 -0400 Subject: [PATCH 4/5] Trim agent design notes from strava.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `llm`-vs-agent-SDK rationale and the reasoning-state caveat now live in CLAUDE.md and WORKLOG.md; keep the inline comment to what the code does. Note at the import why strava_tools is imported as a module — --runlog rebinds strava_tools.RUNLOG_PATH, which a from-import misses. --- strava.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/strava.py b/strava.py index 19c834b..fb29415 100644 --- a/strava.py +++ b/strava.py @@ -23,6 +23,9 @@ from dotenv import load_dotenv from stravalib import Client +# Imported as a module, not `from strava_tools import RUNLOG_PATH`: --runlog overrides the log +# path by reassigning strava_tools.RUNLOG_PATH, and search_runlog reads that module global at +# call time. A `from`-import would bind a separate name here that rebinding never reaches. import strava_tools from strava_prompts import SYSTEM_PROMPT from strava_tools import search_runlog @@ -208,28 +211,6 @@ def main(): # - model.conversation() is a multi-turn object that retains history automatically, so # follow-up questions keep the context of earlier turns (and the activity below). # - # Future extensions — staying on `llm` vs. an agent SDK: - # This tool uses `llm` for the chat, and for tools it uses `llm`'s tool-calling loop - # (`model.conversation(tools=[...])` then `conversation.chain(prompt)`, which auto-executes - # tool calls and re-prompts until the model answers). Tools are plain Python functions whose - # docstring becomes the schema — e.g. a runlog search in strava_tools.py. `llm` keeps this a - # self-contained, minimal-dependency script and stays provider-agnostic. - # If this ever grows into a richer agent — several tools, input/output guardrails, evals, - # persistent memory, or multi-agent handoffs — the OpenAI Agents SDK - # (https://developers.openai.com/api/docs/guides/agents) provides those as first-class - # primitives, at the cost of a heavier dependency and breaking the repo's one-file-per-tool - # convention. Prefer `llm` while the tool surface stays small. - # - # Reasoning state does NOT carry across turns here. `llm`'s default OpenAI plugin talks to - # the Chat Completions API, which never returns a reasoning model's internal reasoning - # tokens, and a conversation replays only the visible transcript (prompts, response text, - # and — inside a chain — tool calls and their results). So on each turn, and on each hop of - # a chain() tool loop, the model re-derives its reasoning from that transcript rather than - # resuming an earlier train of thought. Treat the returned text of search_runlog as the - # only durable state (hence format_entries returns self-contained titles + bodies). - # Preserving reasoning across turns would require the Responses API (via the separate - # llm-openai-plugin, `-m openai/...`) AND that plugin threading previous_response_id or - # encrypted reasoning content through conversation turns — not something wired up here. model = llm.get_model(args.model) conversation = model.conversation(tools=[search_runlog]) From fd55e51699ec3cb80c3b45007faafe259136c857 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 22 Jul 2026 12:04:05 -0400 Subject: [PATCH 5/5] Parse runlog entries by markdown headings RUNLOG.txt delimits entries with "# " headings now, so a title is a heading line rather than "any non-bullet line" -- which lets a body hold prose and not just bullets. Split the text on the heading pattern instead of walking it line by line: re.split's capturing group hands back each title and body whole, so they go straight into a RunlogEntry with no sentinel, no buffer, and no flush step. Make RunlogEntry a frozen dataclass. Nothing depended on its tuple-ness, and value semantics without structural equality mean an entry won't compare equal to a bare 2-tuple or shift positional access if a field is added later. Move search_runlog to the top of the module, ahead of the helpers it composes, so the tool itself is what a reader meets first. Tests: fixtures converted to headings, plus a case for text before the first heading. Both TODO'd as unrun. --- strava_tools.py | 94 ++++++++++++++++++++++++-------------------- test_strava_tools.py | 23 ++++++++--- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/strava_tools.py b/strava_tools.py index 2f505a3..10169b9 100644 --- a/strava_tools.py +++ b/strava_tools.py @@ -1,23 +1,42 @@ # Tools for the strava.py chat agent, exposed to the LLM via `llm`'s tool-calling -# (model.conversation(tools=[...]) + conversation.chain(...)). `llm` builds each tool's -# schema from the function signature and uses its docstring as the description shown to the -# model, so search_runlog's docstring is written for the model to read. -# -# Structure mirrors the repo's fetch_*/process_*/format_* seam: the pure helpers -# (parse_runlog, search_entries, format_entries) do no I/O and are the test targets; -# search_runlog is the thin tool wrapper that reads the file and glues them together. +# (model.conversation(tools=[...]) + conversation.chain(...)). +import re +from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple from sklearn.feature_extraction.text import TfidfVectorizer +# -------------------------------------------- Tool: Search the athlete's past running log + # Default runlog location (the RUNLOG.txt next to this script). strava.py can override this # module attribute from its --runlog flag, and tests point it at a fixture file. RUNLOG_PATH = Path(__file__).parent / "RUNLOG.txt" -class RunlogEntry(NamedTuple): +# `llm` builds each tool's schema from the function signature and uses its docstring +# as the description shown to the model, so search_runlog's docstring is written for the model to read. + +def search_runlog(query: str) -> str: + """Search the athlete's past running log for entries relevant to `query` + (for example: 'half marathon pacing', 'late-run fade', 'hill workouts'). + Returns the most relevant past run notes and coaching advice as text. Call this + to compare the current activity against past runs or to recall prior guidance, + and ground any claims about the athlete's history in what it returns.""" + text = Path(RUNLOG_PATH).read_text() + + # Structure mirrors the repo's fetch_*/process_*/format_* seam: the pure helpers + # (parse_runlog, search_entries, format_entries) do no I/O and are the test targets; + # search_runlog is the thin tool wrapper that reads the file and glues them together. + return format_entries(search_entries(parse_runlog(text), query)) + + +# frozen=True keeps the value semantics a parsed entry wants -- immutable, and compared by +# field so tests can assert on a whole entry -- without being a tuple: an entry won't compare +# equal to a bare ("title", "body") pair or silently unpack, and adding a field later (a parsed +# date, say, normalized in __post_init__) won't shift anyone's positional access. +@dataclass(frozen=True) +class RunlogEntry: title: str body: str @@ -25,30 +44,29 @@ class RunlogEntry(NamedTuple): def parse_runlog(text: str) -> list[RunlogEntry]: """Split raw runlog text into entries. - An entry starts at a "title" line -- a non-blank line that is not a bullet ("- ...") -- - and runs until the next title line. Blank and bullet lines in between form the body. - This tolerates the title / blank-line / bullets layout in RUNLOG.txt without needing a - strict blank-line delimiter between entries. + An entry starts at a "title" line -- a markdown heading ("# ...") -- and runs until the + next heading. Everything in between (blank lines, bullets, prose) forms the body, so an + entry can hold any content as long as it doesn't open a line with "#". The leading "#" + and surrounding whitespace are stripped from the title. Text before the first heading + belongs to no entry and is dropped. """ - entries: list[RunlogEntry] = [] - title: str | None = None - body_lines: list[str] = [] - - def flush() -> None: - if title is not None: - entries.append(RunlogEntry(title=title, body="\n".join(body_lines).strip())) - - for raw in text.splitlines(): - line = raw.rstrip() - is_title = bool(line.strip()) and not line.lstrip().startswith("-") - if is_title: - flush() - title = line.strip() - body_lines = [] - elif title is not None: - body_lines.append(line) - flush() - return entries + # Splitting on the heading pattern hands us each title and body whole, so they go straight + # into the immutable RunlogEntry -- no line-by-line accumulator to build them up in. + # + # re.split with a capturing group keeps what it captures instead of discarding it, so the + # result interleaves captured headings with the text between them: + # [text before the first heading, title, body, title, body, ...] + # Only "(.*)" is captured, so the leading "#"s and spaces go away with the rest of the + # separator. After index 0 the list strictly alternates, hence parts[1::2] is every title + # and parts[2::2] every body, and zip pairs each title with the body that follows it. + # parts[0] is in neither slice -- that's how the pre-heading text gets dropped. + # (n headings always yield 2n+1 parts, a trailing heading getting an empty body, so the + # two slices are always the same length and zip never truncates one away.) + parts = re.split(r"(?m)^[ \t]*#+[ \t]*(.*)$", text) + return [ + RunlogEntry(title.strip(), body.strip()) + for title, body in zip(parts[1::2], parts[2::2]) + ] def search_entries( @@ -99,14 +117,4 @@ def format_entries(entries: list[RunlogEntry]) -> str: string is the only thing that survives into the next turn, so include titles + bodies).""" if not entries: return "No matching runlog entries found." - return "\n\n".join(f"{e.title}\n{e.body}".rstrip() for e in entries) - - -def search_runlog(query: str) -> str: - """Search the athlete's past running log for entries relevant to `query` - (for example: 'half marathon pacing', 'late-run fade', 'hill workouts'). - Returns the most relevant past run notes and coaching advice as text. Call this - to compare the current activity against past runs or to recall prior guidance, - and ground any claims about the athlete's history in what it returns.""" - text = Path(RUNLOG_PATH).read_text() - return format_entries(search_entries(parse_runlog(text), query)) + return "\n\n".join(f"{e.title}\n{e.body}".rstrip() for e in entries) \ No newline at end of file diff --git a/test_strava_tools.py b/test_strava_tools.py index 14f9339..2258349 100644 --- a/test_strava_tools.py +++ b/test_strava_tools.py @@ -5,17 +5,17 @@ search_entries, ) -# A small multi-entry fixture in the RUNLOG.txt shape: a title line, a blank line, bullets. -RUNLOG = """April 26 Half Marathon Run +# A small multi-entry fixture in the RUNLOG.txt shape: a "# " heading, a blank line, bullets. +RUNLOG = """# April 26 Half Marathon Run - The fade in watts alongside the pace drop suggests your legs gave out. Add a weekly long run finishing at goal pace to build muscular endurance. -May 10 Hill Repeats +# May 10 Hill Repeats - Strong power on the climbs; turnover held late. Hills are paying off. -May 17 Easy Recovery Run +# May 17 Easy Recovery Run - Nice and controlled, heart rate stayed in zone 2 the whole way. """ @@ -32,11 +32,24 @@ def test_parse_runlog_splits_entries(): def test_parse_runlog_single_entry_and_trailing_blanks(): - entries = parse_runlog("Solo Run\n\n- Felt good.\n\n\n") + entries = parse_runlog("# Solo Run\n\n- Felt good.\n\n\n") assert len(entries) == 1 assert entries[0] == RunlogEntry(title="Solo Run", body="- Felt good.") +def test_parse_runlog_ignores_text_before_first_heading(): + entries = parse_runlog("stray preamble\n\n# Solo Run\n\nFelt good.\n") + assert entries == [RunlogEntry(title="Solo Run", body="Felt good.")] + + +# TODO: Run these against the "# heading" parse_runlog -- the fixtures above were converted +# from the old bullet-delimited format and test_parse_runlog_ignores_text_before_first_heading +# is new, so neither has been executed yet. +# TODO: Add a case for a heading with no body ("# Heat Advisory" appears that way in the real +# RUNLOG.txt, as does its leading blank line). Those are the two edges of the re.split parse -- +# the dropped parts[0] and the empty trailing body -- and only the first is covered above. + + # The search_entries tests cover only the wrapping logic we wrote around TfidfVectorizer -- # the empty-corpus guard, the score > 0 filter (which drives the no-match sentinel), and the # limit slice. They deliberately do NOT assert ranking order: that's scikit-learn's TF-IDF