diff --git a/.env.example b/.env.example index a358ca3..b115090 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,12 @@ # Required for local plan generation with the default GPT/OpenAI model configuration. OPENAI_API_KEY= -# Optional alternative: set AI_MODE=anthropic and provide this instead of OPENAI_API_KEY. -ANTHROPIC_API_KEY= # Optional observability. If set, traces can include prompt/response content. LANGSMITH_API_KEY= LANGSMITH_PROJECT=paced_coach_local -# Model routing. Options: cost_effective, standard, development, pro, anthropic. +# OpenAI model routing. Options: cost_effective, standard, development, pro. AI_MODE=cost_effective # Local service wiring. @@ -27,6 +25,12 @@ DATABASE_NAME=paced_coach DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/paced_coach REDIS_URL=redis://localhost:6379/0 +# Durable Head Coach execution state. Checkpoints use the same local Postgres database. +HEAD_COACH_CHECKPOINT_RETENTION_DAYS=7 +HEAD_COACH_CHECKPOINT_POOL_MIN_SIZE=1 +HEAD_COACH_CHECKPOINT_POOL_MAX_SIZE=4 +HEAD_COACH_CHECKPOINT_POOL_TIMEOUT_SECONDS=10 + # Existing local DB preservation. Leave empty for fresh installs. LOCAL_OWNER_USER_ID= LOCAL_OWNER_KEY=local-owner @@ -40,29 +44,10 @@ LOCAL_USAGE_SAFETY_BYPASS=false LOCAL_USAGE_DEV_BYPASS=false # Worker task execution limits in seconds. -ANALYSIS_TASK_TIME_LIMIT_SECONDS=1200 -ANALYSIS_TASK_SOFT_TIME_LIMIT_SECONDS=1170 +ANALYSIS_TASK_TIME_LIMIT_SECONDS=1800 +ANALYSIS_TASK_SOFT_TIME_LIMIT_SECONDS=1770 ANALYSIS_TASK_STALE_GRACE_SECONDS=60 -# Provider credential encryption. Required only when saving Strava/WHOOP tokens. -# Generate with: -# pixi run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -FERNET_KEY= - -# Optional Strava OAuth. -STRAVA_OAUTH_ENABLED=false -STRAVA_OAUTH_CLIENT_ID= -STRAVA_OAUTH_CLIENT_SECRET= -STRAVA_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/strava/callback -NEXT_PUBLIC_STRAVA_OAUTH_ENABLED=false - -# Optional WHOOP OAuth. -WHOOP_OAUTH_ENABLED=false -WHOOP_OAUTH_CLIENT_ID= -WHOOP_OAUTH_CLIENT_SECRET= -WHOOP_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/whoop/callback -NEXT_PUBLIC_WHOOP_OAUTH_ENABLED=false - # Coach chat and local API behavior. COACH_THREAD_ITERATION_LIMIT=15 API_FETCH_TIMEOUT_MS=30000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e0a6b3..8b73b9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,20 @@ jobs: !contains(github.event.head_commit.message, '[ci skip]') ) runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: paced_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d paced_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: NEXT_TELEMETRY_DISABLED: "1" API_BASE_URL: "http://127.0.0.1:8000" @@ -25,6 +39,10 @@ jobs: AI_MODE: "cost_effective" APP_ENV: "test" AUTH_MODE: "local" + REDIS_URL: "redis://127.0.0.1:6379/0" + DATABASE_URL: "postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/paced_test" + HEAD_COACH_TEST_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5432/paced_test" + HEAD_COACH_MIGRATION_TEST_ADMIN_URL: "postgresql://postgres:postgres@127.0.0.1:5432/postgres" steps: - uses: actions/checkout@v6 with: @@ -81,7 +99,11 @@ jobs: - name: Type Check (mypy) run: pixi run type-check + - name: Apply test database migrations + run: pixi run alembic -c alembic.ini upgrade head + + - name: Head Coach PostgreSQL durability and migration tests + run: pixi run pytest -q tests/test_head_coach_postgres_integration.py tests/test_release_migration_contract.py + - name: Run tests - env: - ANTHROPIC_API_KEY: "sk-ant-TEST" run: pixi run test diff --git a/.gitignore b/.gitignore index 81b7bfc..6f021a3 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ Thumbs.db .coverage coverage.xml htmlcov/ +# Redacted scanner reports and isolated release-audit exports .tmp/ # Logs and runtime artifacts diff --git a/AGENTS.md b/AGENTS.md index 5f84596..77693eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Mission You are assisting with an AI service that: -1. **Reads training context and activity history** from users and connected sources. +1. **Reads athlete-declared context and locally owned plans, calendar state, competitions, and coaching history.** The v2.2 runtime has no external training-data connectors. 2. **Runs an agentic workflow** (LangGraph) on that context. 3. **Returns coaching outputs** (season roadmap, 28-day execution block, insights, and adaptations). **Architecture**: Python Core (FastAPI/Celery/LangGraph) + Next.js frontend, running local-first by default. @@ -47,6 +47,8 @@ Before starting a complex task: This repo uses a lightweight planning system under `agents_docs/roadmap/`. +Verified engineering learnings live in `docs/solutions/`, organized by category with searchable YAML frontmatter (`module`, `tags`, `problem_type`). They are relevant when implementing or debugging in documented areas. + - **Near-term execution:** `agents_docs/roadmap/now.md` (next 7–14 days) - **Longer-term direction:** `agents_docs/roadmap/roadmap.md` (3–6 months) - **Key decisions:** `agents_docs/roadmap/decision_log.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index d503180..d8f8589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,15 @@ # Changelog -## Unreleased +## 2.2.0 - Unreleased -- Reset the project to a local-first open-source app baseline. -- Removed hosted auth, payment, deployment, internal video, social-story, and generated personal artifact surfaces from the public tree. -- Replaced historical database migrations with a single local-first baseline migration for fresh installs. -- Kept Strava and WHOOP as optional connected-data providers; manual profile and competition setup remains the default first useful path. +- Turned paced.coach into a complete local-first endurance coaching app: describe your training context, generate a season roadmap and 28-day execution block, then keep working with the coach in chat. +- Made the useful first run wearable- and training-data-provider-free. An OpenAI API key and athlete-declared profile, goals, availability, and constraints are enough; no wearable is required. +- Replaced the provider-shaped multi-expert planning graph with one durable Head Coach runtime for initial plans, plan refreshes, coach chat, recap, daily adaptation, and memory extraction. +- Added schema-v3 Season Strategy and 28-day Execution artifacts with rich semantic React components, bounded model self-repair, durable clarification/resume, and version-safe proposal previews. +- Added PostgreSQL LangGraph checkpoints with owner-scoped execution IDs, restart-safe resume, commit-once publication, and bounded terminal retention. +- Made the compact 28-day calendar the primary plan surface while keeping coach rationale and rich semantic guidance available through progressive disclosure. +- Shipped v2.2.0 provider-free: external training-data OAuth, automated source sync, and import surfaces are not part of the public runtime. +- Reset the public repository to an open-source baseline without hosted auth, payment, deployment, private athlete data, or generated personal artifacts. +- Replaced historical database migrations with a local-first baseline and an additive checkpoint-table upgrade for fresh installs. +- Removed the hand-written tool loop, dedicated deep-reasoning formatter agents, unsafe plotting tools, and the `legacy_v1` generation fallback. +- Updated DOMPurify and js-yaml and retained the full frontend, backend, build, and version-governance CI gates. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66e72d1..9e839c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ git diff --check ## Product Boundaries -- Manual Mode must work without Strava/WHOOP. +- Provider-free planning and coach chat must work from athlete-declared context alone. - Connected Mode is optional. - No-provider outputs must not invent activity, load, sleep, HRV, recovery, or readiness claims. - The public setup path must not require hosted auth, hosted payments, vendor deployment accounts, or production infrastructure accounts. diff --git a/LEGAL_TODO.md b/LEGAL_TODO.md index d93a0ca..b06d7cf 100644 --- a/LEGAL_TODO.md +++ b/LEGAL_TODO.md @@ -1,6 +1,6 @@ # LEGAL TODO -Status date: 2026-06-13 +Status date: 2026-07-13 Scope: `web/app` public legal pages (`/impressum`, `/privacy`, `/terms`, `/support`, `/delete`) ## Local-First OSS Review @@ -18,7 +18,9 @@ Scope: `web/app` public legal pages (`/impressum`, `/privacy`, `/terms`, `/suppo ## Privacy Hardening -- [x] Document the local-first processor posture: local infrastructure, AI APIs, Strava, WHOOP, no hidden telemetry, and optional LangSmith. +- [x] Document the local-first processor posture: local infrastructure, configured AI APIs, no hidden telemetry, and optional LangSmith. +- [x] Review the current Strava and WHOOP API terms and remove both connectors from the v2.2.0 public runtime and launch claims. +- [ ] Reassess a future connector only after written provider permission or a clearly compatible API contract is documented. - [x] Add cookie/tracking wording that reflects the current essential-technology posture. - [ ] Verify exact hosting and storage regions before publishing any region-specific privacy claim. - [ ] Verify backup retention wording against the current local setup. @@ -33,4 +35,6 @@ Scope: `web/app` public legal pages (`/impressum`, `/privacy`, `/terms`, `/suppo ## External Review - [ ] Run one legal review by a Germany-based lawyer before broad public distribution. +- [ ] Review the `v2.2.0` release candidate's `/impressum`, `/privacy`, `/terms`, `/support`, and `/delete` pages against the local-first distribution model. +- [ ] Apply counsel-required corrections, then rerun the public release audit and exact-commit CI before publication. - [ ] Recheck all legal pages after each major product change, especially new data sources or managed hosting. diff --git a/README.md b/README.md index ce0d037..7aba770 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ # paced.coach -Local-first AI endurance coaching app for self-coached athletes. +Your season roadmap, next 28 days, and an AI coach that stays with the plan. -paced.coach gives you a full web app, FastAPI backend, Celery worker, local Postgres/Redis stack, and LangGraph-based coaching workflows. It runs on your machine by default. There is no hosted auth, no hosted payments, no production deploy requirement, and no required training-provider connection. +Describe your goals, training history, availability, and constraints. paced.coach turns that athlete-declared context into a personal season strategy and a day-by-day execution block, then carries the same context into coach chat. -You bring one LLM key. Strava and WHOOP OAuth are optional when you want connected daily sync and weekly recaps. +**No wearable required.** You bring an OpenAI API key and the context only you know. Version 2.2.0 is deliberately provider-free: no activity-platform or recovery-device account is connected to the app. -Not affiliated with Strava or WHOOP. Not medical advice. +The complete app runs on your machine by default: Next.js frontend, FastAPI backend, Celery/LangGraph coaching workflows, and local Postgres/Redis. There is no hosted auth, hosted payment, or production deployment requirement. + +Not medical advice. + +> **Pre-candidate asset notice:** The README images were generated from sanitized fixture data and contain no local account data, but they show the July pre-Head-Coach renderer. They must be recaptured from the sanitized schema-v3 demo before the v2.2.0 candidate is certified. ![paced.coach local-first AI endurance coach](docs/assets/readme/paced-coach-hero.png) ## Preview -The screenshots below are generated from the public `/demo` route using sanitized fixture data. They do not read a local database or real athlete account. +The screenshots below were generated from the public `/demo` route using sanitized fixture data. They do not read a local database or real athlete account.

- paced.coach dashboard with daily focus, recovery gates, weekly recap, daily sync, and season progress - paced.coach generated training plan with season roadmap and 28-day calendar + paced.coach generated training plan with season roadmap and 28-day calendar

![paced.coach coach workspace preview](docs/assets/readme/paced-coach-coach.png) @@ -25,11 +28,11 @@ Open the same preview locally at `http://localhost:3000/demo` after `make start` ## What You Get -- A local web app for profile, race calendar, plan generation, active plan review, and coach conversations. -- AI-generated season roadmap plus a 28-day execution block. +- One continuous coaching flow: athlete profile and goals, season roadmap, 28-day execution block, then coach conversations against the actual plan. +- A calendar-first view of every generated session, with the longer season strategy always in reach. - Versioned plan renderers for coach report, season strategy, and calendar-style weekly plan views. -- Optional Strava and WHOOP OAuth for connected daily sync and weekly recap. -- Explicit confidence boundaries when only declared profile/goals are available. +- A provider-free coaching model that reasons from what the athlete explicitly declares and what the app has generated. +- Explicit confidence boundaries: missing activity, load, sleep, HRV, recovery, and readiness evidence is never invented. - Local-first data posture: your app database is your local Postgres volume. ## Requirements @@ -37,7 +40,7 @@ Open the same preview locally at `http://localhost:3000/demo` after `make start` - Docker with Docker Compose v2 - Pixi - Node.js 24 and npm -- One LLM key: `OPENAI_API_KEY` for the default mode, or `ANTHROPIC_API_KEY` with `AI_MODE=anthropic` +- One OpenAI API key: `OPENAI_API_KEY` ## Quick Start @@ -49,8 +52,6 @@ cp .env.example .env cp web/app/.env.example web/app/.env.local # Edit .env and set OPENAI_API_KEY. -# Alternative: set AI_MODE=anthropic and ANTHROPIC_API_KEY instead. -# Optional for Strava/WHOOP later: generate FERNET_KEY and provider OAuth values. make setup make start @@ -74,7 +75,7 @@ Open: 5. Read the active plan at `/app/plan`. 6. Ask questions in `/app/coach`. -Strava and WHOOP are not required for this path. Without connected data, the coach must not claim recent load, compliance, HRV, sleep, recovery, or readiness trends. +No wearable is required for this path. Your OpenAI API key plus your declared profile, goals, availability, constraints, and race calendar form the coaching baseline. The coach must not claim recent load, compliance, HRV, sleep, recovery, or readiness trends unless you explicitly provide that information. ## Environment @@ -92,38 +93,7 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/paced_coach REDIS_URL=redis://localhost:6379/0 ``` -Anthropic alternative: - -```bash -ANTHROPIC_API_KEY=... -AI_MODE=anthropic -``` - -Optional connected-mode values: - -```bash -FERNET_KEY=... -STRAVA_OAUTH_ENABLED=true -STRAVA_OAUTH_CLIENT_ID=... -STRAVA_OAUTH_CLIENT_SECRET=... -STRAVA_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/strava/callback - -WHOOP_OAUTH_ENABLED=true -WHOOP_OAUTH_CLIENT_ID=... -WHOOP_OAUTH_CLIENT_SECRET=... -WHOOP_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/whoop/callback -``` - -Generate `FERNET_KEY` with: - -```bash -pixi run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -``` - -Detailed connector setup: - -- [Strava local OAuth](docs/local-first/connect-strava.md) -- [WHOOP local OAuth](docs/local-first/connect-whoop.md) +External training-data connectors are intentionally not shipped in v2.2.0. The full plan and coach flow works from declared context alone. ## Local Data @@ -143,7 +113,7 @@ pixi run python scripts/local_owner_report.py Fresh installs use `LOCAL_OWNER_KEY=local-owner` as the stable owner key. Existing databases should prefer `LOCAL_OWNER_USER_ID` when there is already training data. -Fresh public installs use one baseline database migration: `001_initial_local_first`. If you already ran a pre-public branch with older migration revisions, back up your database and follow [docs/local-first/data-preservation.md](docs/local-first/data-preservation.md) before running the app. +Fresh public installs apply the local-first baseline `001_initial_local_first` and then the additive Head Coach checkpoint upgrade `002_head_coach_checkpoints`. If you already ran a pre-public branch with older migration revisions, back up your database and follow [docs/local-first/data-preservation.md](docs/local-first/data-preservation.md) before running the app. More detail: @@ -186,7 +156,7 @@ pixi run worker-beat ```text api/ FastAPI routes, models, migrations, local owner auth worker/ Celery app and background plan generation tasks -services/ai/ LangGraph workflows, prompts, schemas, coach agents +services/ai/ Shared Head Coach runtime, semantic profiles, artifacts, and evals web/app/ Next.js app docs/local-first/ Local setup, privacy, and data docs agents_docs/ Internal planning and architecture notes diff --git a/SECURITY.md b/SECURITY.md index 3e1e835..7af40df 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,6 @@ Include: Never commit real credentials. This includes: - LLM API keys -- Strava/WHOOP OAuth secrets - provider access or refresh tokens - database URLs with nonlocal credentials - Fernet keys @@ -41,14 +40,21 @@ Local Postgres data can contain sensitive training and coaching history. Back up The local app can send data to: - the configured LLM provider during generation/coaching -- Strava/WHOOP if OAuth is configured - LangSmith if `LANGSMITH_API_KEY` is set Leave optional integrations unset if you do not want those network paths. ## Public Release Gate -Before publishing a public release, run a release audit covering: +Before publishing a public release, run the non-destructive audit from a clean release-candidate commit: + +```bash +bash scripts/release_audit.sh +``` + +The audit uses pinned Gitleaks `v8.30.1` scans against isolated copies of tracked files and publishable Git history. It reports ignored local secret/data paths by name only and never scans their contents. Redacted reports and temporary scan repositories stay under ignored `.tmp/release-audit/`. + +The gate covers: - working tree cleanliness - ignored local secret files @@ -57,3 +63,5 @@ Before publishing a public release, run a release audit covering: - workflow secret references - screenshots and fixtures - hosted ops leftovers + +If the audit reports a possible secret, rotate the credential first. Do not rewrite history, delete local artifacts, or remove Docker volumes without explicit maintainer approval and a backup/data-preservation check. diff --git a/agents_docs/architecture/ai_ui_contract.md b/agents_docs/architecture/ai_ui_contract.md index 6358c8f..2955b4e 100644 --- a/agents_docs/architecture/ai_ui_contract.md +++ b/agents_docs/architecture/ai_ui_contract.md @@ -1,179 +1,85 @@ -# AI → UI Contract: Markdown-First Architecture - -**Status**: Implemented (schema v2 live) -**Date**: 2026-02-14 - ---- - -## Architecture - -``` -Planners/Synthesis → rich markdown → Formatter nodes → UI Blocks (HTML Blocks schema) +# Head Coach, Artifact, and UI Ownership Contract + +## Source of ownership + +paced.coach has one accountable coaching owner: the Head Coach. Initial planning, plan refreshes, coach chat, and memory extraction use semantic run profiles over the shared LangChain `create_agent` factory. A run profile selects the model role, reasoning effort, tool capabilities, mutation authority, and call limits; entry points do not select model names directly. The v2.2 runtime has no wearable/training-data connectors, daily sync, or weekly recap producer. + +The runtime has four distinct ownership layers: + +| Layer | Owns | Must not own | +| --- | --- | --- | +| Head Coach | Coaching judgment, assumptions, uncertainty, proposal intent, semantic presentation intent | Direct database mutation, auth, quota, retries, CSS | +| LangGraph execution | Durable progress, checkpoints, interrupts, resume state | Canonical plans or user-visible history | +| API/worker services | Owner checks, idempotency, validation, proposal acceptance, atomic persistence, cost linkage | Coaching heuristics or rule-authored fallback plans | +| React | Accessible components, layout, responsive behavior, safe Markdown rendering | Reinterpreting coaching decisions or accepting arbitrary model CSS/HTML | + +PostgreSQL owns canonical athlete/profile/calendar records, Coach Events, accepted plans, and proposal state. LangGraph checkpoints are execution state with bounded retention; they are never the canonical coaching record. + +## Runtime flow + +```text +Athlete-declared context + canonical local records + │ + ▼ + semantic Head Coach profile + (complete context, capability tools) + │ + validated structured output + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + direct answer proposal or plan artifacts + │ + deterministic validation + │ + explicit commit / accept boundary + │ + ▼ + canonical PostgreSQL state ``` -**Principle**: LLMs reason freely in markdown. Dedicated formatter nodes convert markdown to structured UI documents. The UI gets stable IDs and layout anchors; the LLM gets creative freedom. +Athlete declarations and canonical local records are the planning evidence. The active tool registry reads the athlete profile, competitions, current season strategy, and current execution block; it contains no provider tools. Historical provider-era rows remain only where required for non-destructive local reset and data ownership. -### Data Flow +## Artifact contract -```mermaid -graph LR - subgraph "Analysis Stream" - SN["Synthesis"] -->|"markdown"| PR["Plot Resolution"] - PR --> AF["Analysis Formatter"] - AF -->|"UiAnalysis"| State - end +New season and execution plans use `schema_version: 3` Pydantic artifacts from `services/ai/head_coach/artifacts.py`. - subgraph "Planning Stream" - SP["Season Planner"] -->|"markdown → state"| WP["Weekly Planner"] - WP -->|"markdown"| PF["Plan Formatter"] - PF -->|"UiSeasonPlan + UiWeeklyPlan"| State - end -``` +They contain: -### Key design choices - -| Choice | Rationale | -|---|---| -| Planners output **markdown**, not structured JSON | LLMs reason best in natural language. No schema constraints limiting output quality. | -| Season plan markdown flows to weekly planner as context | Natural data flow — no information loss from forced structuring. | -| **Formatter nodes** convert markdown → UI blocks | Narrow agent scope (Skill #3). Schema changes are isolated to formatters. | -| `UiHtmlBlock` containers (`blocks[]`, `notes_blocks[]`) | Rich HTML stays inside typed blocks with stable keys/variants for deterministic rendering. | -| Deterministic **post-fill** for metadata | `plan_id`, `athlete_name`, `created_at` are set by node code, not the LLM. | -| No prescriptive count constraints in prompts | Agents decide how many KPIs, sections, phases, weeks based on the data. | - ---- - -## Schema - -### Weekly Plan - -```python -class UiHtmlBlock(BaseModel): - type: Literal["html"] = "html" - key: str - variant: Literal["workout", "support", "fueling", "checklist", "callout", "notes", "meta", "generic"] - title: str | None = None - tone: Literal["good", "warning", "danger", "neutral"] | None = None - content_html: str - -class UiDayPlan(BaseModel): - day_id: str - date: datetime.date - day_label: str | None = None - focus_type: str | None = None - blocks: list[UiHtmlBlock] = [] - -class UiWeekPlan(BaseModel): - week_id: str - week_label: str | None = None - start_date: datetime.date - end_date: datetime.date - notes_blocks: list[UiHtmlBlock] = [] - days: list[UiDayPlan] - -class UiWeeklyPlan(BaseModel): - type: Literal["weekly_plan"] = "weekly_plan" - plan_id: str = "" # post-filled - schema_version: int = 2 - version: int = 1 - athlete_name: str = "" # post-filled - created_at: str | None = None # post-filled - global_blocks: list[UiHtmlBlock] = [] - weeks: list[UiWeekPlan] -``` +- stable plan, week, day, session, section, and block IDs; +- typed dates, duration, intensity, completion, assumptions, evidence, safety concerns, and unresolved questions; +- a Decision Ledger entry explaining material choices; +- semantic blocks such as workout, intervals, callout, checklist, fueling, recovery, notes, data table, timeline, and disclosure; +- Markdown narrative fields, never model-authored HTML or CSS. -### Season Plan - -```python -class UiSeasonPhase(BaseModel): - phase_id: str - title: str - start_date: datetime.date - end_date: datetime.date - blocks: list[UiHtmlBlock] = [] - -class UiSeasonPlan(BaseModel): - type: Literal["season_plan"] = "season_plan" - plan_id: str = "" # post-filled - schema_version: int = 2 - version: int = 1 - athlete_name: str = "" # post-filled - start_date: datetime.date - end_date: datetime.date - global_blocks: list[UiHtmlBlock] = [] - phases: list[UiSeasonPhase] -``` +The Head Coach chooses content hierarchy and semantic component intent. React maps that intent to a bounded component catalog. An optional UI Composer may reorganize presentation only when it preserves the artifact semantic hash; it cannot alter coaching facts or prescriptions. -### Analysis - -```python -class UiKpi(BaseModel): - kpi_id: str - label: str - value: str - trend: str | None = None - status: Literal["good", "warning", "danger", "neutral"] = "neutral" - -class UiAnalysisSection(BaseModel): - section_id: str - title: str - tone: Literal["neutral", "good", "warning", "danger"] = "neutral" - blocks: list[UiHtmlBlock] = [] - -class UiAnalysis(BaseModel): - type: Literal["analysis"] = "analysis" - analysis_id: str = "" # post-filled - schema_version: int = 2 - version: int = 1 - athlete_name: str = "" # post-filled - created_at: str | None = None # post-filled - kpis: list[UiKpi] - sections: list[UiAnalysisSection] -``` - ---- - -## Design System - -Formatter prompts reference CSS classes so LLMs produce consistent, styleable HTML: +Stored v1 artifacts remain readable through their versioned historical renderers. New generation and mutation paths emit v3. Unknown schema versions fail visibly instead of being guessed. -| Class | Purpose | -|---|---| -| `.kpi-table` | Metric tables with headers and zebra rows | -| `.callout-warning` / `.callout-good` / `.callout-danger` | Highlighted boxes | -| `.workout` / `.workout-title` / `.workout-meta` | Workout containers | -| `.checklist` | `` items | -| `.code-block` | Monospace data blocks | -| `.emoji-label` | Inline emoji + text pairs | +## Validation, repair, and failure -The UI provides a shared CSS stylesheet. The LLM writes semantic HTML; the UI controls visual appearance. - -### Checkbox IDs - -```html - -``` +Pydantic structured output is the runtime boundary. Invalid model output is returned to the responsible model for a bounded repair attempt through `ToolStrategy`. Deterministic code may validate identity, dates, schemas, authorization, idempotency, and mutation conflicts. -Sequential `--N` suffixes per day. Frontend persists state keyed to `day_id + "--" + index`. +There is no rule-authored coaching fallback. If repair is exhausted, the run fails visibly, sanitized diagnostics are recorded, and the previous canonical artifact remains untouched. ---- +## Mutation contract -## Frontend Integration (Implemented) +The model may return proposal operations but cannot write an active plan. Services dispatch operations by the active artifact's `schema_version`, build a before/after preview, and persist the proposal against the expected plan version. Only explicit acceptance applies it; stale versions fail with a conflict, and retries remain idempotent. -UI blocks are produced by formatter nodes and persisted in active plan/analysis payloads. The frontend renders `blocks[]` and `notes_blocks[]` for weekly/season/analysis views (including demo fixtures), branching on `schema_version`. +Initial or full plan generation commits the Season Strategy, 28-day Execution Plan, Decision Ledger events, usage linkage, and job result atomically. A clarification pauses through a durable interrupt and resumes the same execution thread. -Current paths in use: +Generation admission is serialized per owner with a PostgreSQL transaction advisory lock. `pending`, `running`, and `awaiting_input` all retain the generation slot. Resume idempotency binds the request key to an answer hash; terminal state retains only the key-plus-hash receipt. If checkpoint finalization fails after the domain transaction commits, the committed job and matching `source_job_id` plan rows remain authoritative and cannot be rewritten as failed. -1. **API payloads**: UI schemas are serialized from DB-backed active documents. -2. **React renderers**: Structural containers (week/day/section cards) render block HTML fragments with shared styles. +## Observability contract ---- +Product progress uses semantic lifecycle events such as understanding context, designing strategy, reviewing constraints, awaiting input, building the execution block, and saving the plan. Traces may contain sanitized run metadata, tool names, artifact IDs, token/cost totals, and validation outcomes. They must not contain credentials, provider tokens, raw private context, or hidden reasoning. -## References +## Source files -- [Decision log entry](../roadmap/decision_log.md) (2026-02-14) -- Schema source: `services/ai/langgraph/schemas/ui_blocks.py` -- Formatter nodes: `analysis_formatter_node.py`, `plan_formatter_node.py` +- Shared agent factory and profiles: `services/ai/head_coach/agent.py`, `run_profiles.py` +- Durable execution: `services/ai/head_coach/graph.py`, `checkpointing.py` +- Canonical artifacts: `services/ai/head_coach/artifacts.py` +- Proposal validation: `services/ai/coach/schemas.py`, `patch_apply.py` +- API mutation boundary: `api/services/coach_turn.py`, `coach_patch_ops.py` +- Planning lifecycle: `api/services/plan_generation_lock.py`, `analysis_resume.py`, `worker/tasks.py` +- Versioned React rendering: `web/app/src/components/plan-viewer/versioned/` diff --git a/agents_docs/roadmap/decision_log.md b/agents_docs/roadmap/decision_log.md index dd1ee02..cf01ed1 100644 --- a/agents_docs/roadmap/decision_log.md +++ b/agents_docs/roadmap/decision_log.md @@ -2,9 +2,27 @@ Keep this file short. It should capture durable product and architecture decisions, not day-by-day cleanup history. +## 2026-07-19 — One Head Coach owns coaching decisions end to end + +- **Decision:** Route initial planning, plan refreshes, coach chat, weekly recap, daily adaptation, and memory extraction through one shared Head Coach runtime with semantic run profiles. Retire the mandatory provider-shaped expert fan-out, hand-written tool loop, deep-reasoning formatter agents, and legacy generation fallback. +- **Why:** A single accountable owner preserves context and decision continuity, while `create_agent`, durable LangGraph checkpoints, capability-gated tools, and typed structured output provide the modern execution substrate without replacing model judgment with heuristics. +- **Implication:** PostgreSQL owns canonical plans and Coach Events; checkpoints own resumable execution; services own validation/idempotency/commit authority; React owns deterministic presentation of model-selected semantic components. Invalid output receives bounded model repair and then fails visibly without a rule-authored coaching fallback. + +## 2026-07-13 — v2.2.0 ships provider-free + +- **Decision:** Remove Strava and WHOOP OAuth, import, sync, recap, and public configuration surfaces from the v2.2.0 runtime. Preserve legacy database rows only for non-destructive upgrade compatibility. +- **Why:** Athlete-declared context already produces a complete product, while the reviewed provider API terms do not provide a safe default contract for the planned AI-processing and public-launch posture. +- **Implication:** The release makes no connector claim and performs no provider request. A future connector requires documented permission or a compatible contract, a fresh legal/security review, and a separate product decision. + +## 2026-07-13 — Athlete value leads; no wearable required + +- **Decision:** Position paced.coach as a complete AI endurance coach that starts from athlete-declared context. The primary journey is season roadmap → 28-day execution block → plan-aware coach chat, with no wearable required. +- **Why:** Goals, history, availability, constraints, and ongoing athlete feedback are strong coaching inputs in their own right. Requiring a data platform would narrow both the product and the open-source story. +- **Implication:** Public copy and first-use UX name one supported LLM key plus declared context as the full baseline. Missing device evidence is explicit and never invented. + ## 2026-06-13 — Public repository becomes local-first baseline -- **Decision:** Treat the repository as the open-source product artifact: a local single-owner AI endurance coach with optional connected providers. +- **Decision:** Treat the repository as the open-source product artifact: a local single-owner AI endurance coach. - **Why:** The useful public artifact is the full web app plus API/worker/AI workflow, not a hosted service clone or a return to the older CLI-only project. - **Implication:** Setup docs, workflows, migrations, agent instructions, and tests must assume local-first operation by default. @@ -14,11 +32,11 @@ Keep this file short. It should capture durable product and architecture decisio - **Why:** Public fresh installs should not replay obsolete auth, payment, provider, or deployment history before reaching the current schema. - **Implication:** Existing private/local databases are personal state; public contributors start from the baseline schema. -## 2026-06-13 — Connected providers are optional data sources, not login +## 2026-06-13 — Connected providers were optional data sources, not login (superseded 2026-07-13) - **Decision:** Keep Strava and WHOOP OAuth as optional data connectors. The app remains useful without either provider. - **Why:** First-use value should come from profile, competitions, constraints, and coach reasoning. Connected data improves context but should not be a hard requirement. -- **Implication:** Provider failures must stay connector-scoped and must not block manual planning. +- **Implication:** Superseded by the provider-free v2.2.0 decision above. ## 2026-06-13 — No tracked internal marketing or generated personal artifacts @@ -28,6 +46,6 @@ Keep this file short. It should capture durable product and architecture decisio ## 2026-04-03 — Plan-first coaching remains the product architecture -- **Decision:** Position the product around season roadmap, living 28-day block, coach proposals, daily updates, and weekly recaps. +- **Decision:** Position the product around season roadmap, living 28-day block, and plan-aware coach conversations. - **Why:** The durable product value is an adaptive coaching loop, not a one-off dashboard or connector utility. - **Implication:** Analysis and readiness views support the coach loop; they are not the product front door. diff --git a/agents_docs/roadmap/now.md b/agents_docs/roadmap/now.md index b7816f4..498aa12 100644 --- a/agents_docs/roadmap/now.md +++ b/agents_docs/roadmap/now.md @@ -4,11 +4,13 @@ ## Current Focus -Ship the repository as a clean local-first open-source AI endurance coach: +Ship `v2.2.0` as a polished athlete-first, local-first AI endurance coach: +- Lead with the complete outcome: season roadmap, 28-day execution block, and plan-aware coach chat. +- Make “No wearable required” explicit while naming the supported LLM key and athlete-declared context the baseline needs. - One local owner by default. -- Manual profile, competitions, and plan generation work without connected providers. -- Strava and WHOOP are optional OAuth data connectors. +- Profile, competitions, plan generation, calendar, and coach chat form the complete provider-free product. +- External training-data connectors are deferred beyond v2.2.0 pending a compatible provider contract or written permission. - No public setup dependency on hosted auth, payments, managed deployment, or vendor secrets. - No tracked local env files, private athlete data, generated personal artifacts, or stale internal marketing tooling. @@ -20,20 +22,30 @@ Ship the repository as a clean local-first open-source AI endurance coach: - [x] Remove hosted auth and payment product surfaces. - [x] Remove internal video, social-story, and example-capture tooling. - [x] Replace historical migration chains with a single local-first baseline migration. -- [ ] Run final tracked-file scans before publishing. +- [x] Add and pass the non-destructive tracked-file and full-history release audit. ### 2) Local Happy Path - [x] `http://localhost:3000` opens the app path. - [x] Plan generation works from local profile, competitions, and an LLM key. -- [x] Daily sync and weekly recap remain available when connected data exists. -- [x] Strava and WHOOP OAuth remain optional. +- [x] Remove OAuth, provider import, daily sync, and weekly recap from the public v2.2.0 runtime. +- [x] Complete the polished provider-free first-run smoke with a real supported LLM. ### 3) Contributor Readiness - [x] Keep `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, and `docs/local-first/**` aligned with local-first setup. - [x] Keep repo agent guidance focused on current web/API/worker/AI workflows. -- [ ] Run full backend and frontend verification after cleanup. +- [x] Run full backend and frontend verification after cleanup. +- [ ] Complete external legal review before public release or promotion. + +### 4) Head Coach Runtime Migration + +- [x] Freeze the provider-free 13-call baseline with synthetic quality, safety, trajectory, and cost gates. +- [x] Define shared Head Coach contracts, runtime context, capability-based tools, and semantic reasoning profiles. +- [x] Add durable checkpointing after explicit approval for the checkpoint-table migration. +- [x] Introduce canonical schema-v3 season/execution artifacts, rich semantic React renderers, bounded LLM repair, and schema-safe plan mutations. +- [x] Ship the provider-free schema-v3 initial-planning slice behind a reversible workflow selector. +- [x] Converge ongoing coaching surfaces and retire the legacy provider-shaped graph after release gates pass. ## Not In Scope Now diff --git a/agents_docs/roadmap/roadmap.md b/agents_docs/roadmap/roadmap.md index bfe4bd2..b566182 100644 --- a/agents_docs/roadmap/roadmap.md +++ b/agents_docs/roadmap/roadmap.md @@ -6,7 +6,7 @@ This doc changes slowly. It describes product direction for the local-first open 1. **Plan-first coaching loop**: season roadmap, living 28-day block, coach proposals, daily update, weekly recap. 2. **Local-first ownership**: one local owner by default, no account service required for the first useful run. -3. **Provider-optional context**: manual profile and competitions work alone; Strava and WHOOP add richer context when configured. +3. **Provider-free context**: profile, competitions, constraints, plan history, and coach dialogue form the complete coaching context. 4. **Confidence-aware AI coaching**: the coach should state limits clearly when data is sparse or disconnected. 5. **Agent-native architecture**: agents receive rich context and make coaching judgments; deterministic code handles infrastructure, validation, and persistence. 6. **Mobile-ready web app**: responsive PWA first, native wrapper only if real usage justifies it. @@ -48,7 +48,7 @@ Avoid positioning the product as: ### Phase 3 — Connected Coach Context -- Make Strava activity history and WHOOP readiness inputs feed provider-neutral context. +- Reconsider external training-data connectors only after a compatible provider contract or written permission is documented. - Keep provider data read-only in v1. - Preserve source snapshots enough to debug connector parsing. - Make provider failures non-blocking for manual planning. @@ -65,25 +65,14 @@ Avoid positioning the product as: - Add German UI and AI-response support before broader localization. - Consider a native wrapper only after mobile usage proves the need. -## Technical Debt Backlog +## Architecture Baseline -### Plan-First Migration +The provider-shaped `metrics / physiology / activity` fan-out and hand-written agent loop have been retired. Current generation and ongoing coaching use one Head Coach built with LangChain `create_agent`, semantic run profiles, capability-gated tools, durable LangGraph execution, canonical schema-v3 artifacts, and proposal-driven mutation boundaries. -The current analysis/planning graph still carries some `metrics / physiology / activity` workflow shape. +Near-term architecture work should focus on measured quality rather than adding orchestration layers: -Target: - -- provider-neutral sufficiency gating, -- canonical activity history, -- plan-first orchestration, -- proposal-driven execution coaching. - -### LangGraph Agent Loop - -`handle_tool_calling_in_node` is a hand-rolled agentic loop. It works, but tool executions are not ideal for tracing. - -Target: - -- Evaluate LangGraph's `create_react_agent` for recap and coach agents. -- Keep structured-output validation after the agent loop. -- Preserve existing behavior until replacement tests are strong. +- expand provider-free trajectory, safety, and adaptation eval cases; +- calibrate reasoning profiles from quality/latency evidence; +- add a specialist or Deep Agents research path only when an eval demonstrates material value; +- keep optional provider evidence read-only and absent from the tool surface when disconnected; +- retain v1 renderers solely for historical local artifacts while all new generation stays on v3. diff --git a/api/config.py b/api/config.py index 6a2ac16..af1b3aa 100644 --- a/api/config.py +++ b/api/config.py @@ -67,21 +67,31 @@ class Settings(BaseSettings): # Coach chat coach_thread_iteration_limit: int = Field(default=15, validation_alias="COACH_THREAD_ITERATION_LIMIT") - # Whoop OAuth 2.0 (live) - whoop_oauth_enabled: bool = False - whoop_oauth_client_id: str = "" - whoop_oauth_client_secret: str = "" - # This should point to the web callback route (e.g. https://app.example.com/app/api/oauth/whoop/callback), - # not the API callback URL, since the browser redirect will not include an Authorization header. - whoop_oauth_redirect_uri: str = "" - - # Strava OAuth 2.0 (live) - strava_oauth_enabled: bool = False - strava_oauth_client_id: str = "" - strava_oauth_client_secret: str = "" - # This should point to the web callback route (e.g. https://app.example.com/app/api/oauth/strava/callback), - # not the API callback URL, since the browser redirect will not include an Authorization header. - strava_oauth_redirect_uri: str = "" + # Durable Head Coach execution state + head_coach_checkpoint_retention_days: int = Field( + default=7, + ge=1, + le=90, + validation_alias="HEAD_COACH_CHECKPOINT_RETENTION_DAYS", + ) + head_coach_checkpoint_pool_min_size: int = Field( + default=1, + ge=1, + le=10, + validation_alias="HEAD_COACH_CHECKPOINT_POOL_MIN_SIZE", + ) + head_coach_checkpoint_pool_max_size: int = Field( + default=4, + ge=1, + le=20, + validation_alias="HEAD_COACH_CHECKPOINT_POOL_MAX_SIZE", + ) + head_coach_checkpoint_pool_timeout_seconds: float = Field( + default=10.0, + gt=0.0, + le=120.0, + validation_alias="HEAD_COACH_CHECKPOINT_POOL_TIMEOUT_SECONDS", + ) model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="") diff --git a/api/main.py b/api/main.py index 8636b35..12c6f95 100644 --- a/api/main.py +++ b/api/main.py @@ -12,15 +12,10 @@ athlete_profile, coach, competitions, - daily, dashboard, health, - integrations, plans, - strava_oauth, uploads, - weekly_recap, - whoop_oauth, ) from core.version_manifest import get_release_version @@ -43,7 +38,7 @@ def create_app() -> FastAPI: app = FastAPI( title=settings.app_name, - description="AI-powered endurance coaching with connected activity and readiness data", + description="Local-first AI endurance coaching from athlete-declared context", version=get_release_version(), lifespan=lifespan, ) @@ -57,19 +52,14 @@ def create_app() -> FastAPI: ) app.include_router(health.router, tags=["Health"]) - app.include_router(strava_oauth.router, prefix="/api/oauth/strava", tags=["StravaOAuth"]) - app.include_router(whoop_oauth.router, prefix="/api/oauth/whoop", tags=["WhoopOAuth"]) - app.include_router(integrations.router, prefix="/api/integrations", tags=["Integrations"]) app.include_router(analysis.router, prefix="/api/analysis", tags=["Analysis"]) app.include_router(athlete_profile.router, prefix="/api/athlete-profile", tags=["AthleteProfile"]) app.include_router(competitions.router, prefix="/api/competitions", tags=["Competitions"]) - app.include_router(daily.router, prefix="/api/daily", tags=["Daily"]) app.include_router(dashboard.router, prefix="/api/dashboard", tags=["Dashboard"]) app.include_router(uploads.router, prefix="/api/uploads", tags=["Uploads"]) app.include_router(account.router, prefix="/api/account", tags=["Account"]) app.include_router(plans.router, prefix="/api/plans", tags=["Plans"]) app.include_router(coach.router, prefix="/api/coach", tags=["Coach"]) - app.include_router(weekly_recap.router, prefix="/api/weekly-recap", tags=["WeeklyRecap"]) return app diff --git a/api/migrations/versions/002_add_langgraph_checkpoint_tables.py b/api/migrations/versions/002_add_langgraph_checkpoint_tables.py new file mode 100644 index 0000000..ed28aa8 --- /dev/null +++ b/api/migrations/versions/002_add_langgraph_checkpoint_tables.py @@ -0,0 +1,87 @@ +"""add LangGraph PostgreSQL checkpoint tables + +Revision ID: 002_head_coach_checkpoints +Revises: 001_initial_local_first +Create Date: 2026-07-19 + +The schema mirrors langgraph-checkpoint-postgres 3.1.0 migrations 0..9. +Review this migration against the pinned package before upgrading it. +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "002_head_coach_checkpoints" +down_revision = "001_initial_local_first" +branch_labels = None +depends_on = None + +_CHECKPOINT_SCHEMA_VERSION = 9 + + +def upgrade() -> None: + op.create_table( + "checkpoint_migrations", + sa.Column("v", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("v"), + ) + op.create_table( + "checkpoints", + sa.Column("thread_id", sa.Text(), nullable=False), + sa.Column("checkpoint_ns", sa.Text(), server_default="", nullable=False), + sa.Column("checkpoint_id", sa.Text(), nullable=False), + sa.Column("parent_checkpoint_id", sa.Text(), nullable=True), + sa.Column("type", sa.Text(), nullable=True), + sa.Column("checkpoint", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.PrimaryKeyConstraint("thread_id", "checkpoint_ns", "checkpoint_id"), + ) + op.create_table( + "checkpoint_blobs", + sa.Column("thread_id", sa.Text(), nullable=False), + sa.Column("checkpoint_ns", sa.Text(), server_default="", nullable=False), + sa.Column("channel", sa.Text(), nullable=False), + sa.Column("version", sa.Text(), nullable=False), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("blob", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("thread_id", "checkpoint_ns", "channel", "version"), + ) + op.create_table( + "checkpoint_writes", + sa.Column("thread_id", sa.Text(), nullable=False), + sa.Column("checkpoint_ns", sa.Text(), server_default="", nullable=False), + sa.Column("checkpoint_id", sa.Text(), nullable=False), + sa.Column("task_id", sa.Text(), nullable=False), + sa.Column("task_path", sa.Text(), server_default="", nullable=False), + sa.Column("idx", sa.Integer(), nullable=False), + sa.Column("channel", sa.Text(), nullable=False), + sa.Column("type", sa.Text(), nullable=True), + sa.Column("blob", sa.LargeBinary(), nullable=False), + sa.PrimaryKeyConstraint("thread_id", "checkpoint_ns", "checkpoint_id", "task_id", "idx"), + ) + + op.create_index("checkpoints_thread_id_idx", "checkpoints", ["thread_id"]) + op.create_index("checkpoint_blobs_thread_id_idx", "checkpoint_blobs", ["thread_id"]) + op.create_index("checkpoint_writes_thread_id_idx", "checkpoint_writes", ["thread_id"]) + op.execute( + sa.text( + "INSERT INTO checkpoint_migrations (v) " + "SELECT generate_series(0, :schema_version)" + ).bindparams(schema_version=_CHECKPOINT_SCHEMA_VERSION) + ) + + +def downgrade() -> None: + op.drop_index("checkpoint_writes_thread_id_idx", table_name="checkpoint_writes") + op.drop_index("checkpoint_blobs_thread_id_idx", table_name="checkpoint_blobs") + op.drop_index("checkpoints_thread_id_idx", table_name="checkpoints") + op.drop_table("checkpoint_writes") + op.drop_table("checkpoint_blobs") + op.drop_table("checkpoints") + op.drop_table("checkpoint_migrations") diff --git a/api/models/job.py b/api/models/job.py index 576c8a8..aa442a5 100644 --- a/api/models/job.py +++ b/api/models/job.py @@ -13,6 +13,7 @@ class JobStatus(StrEnum): PENDING = "pending" RUNNING = "running" + AWAITING_INPUT = "awaiting_input" COMPLETED = "completed" CANCELLED = "cancelled" FAILED = "failed" diff --git a/api/routers/__init__.py b/api/routers/__init__.py index ea0326a..7196d56 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -7,11 +7,7 @@ competitions, dashboard, health, - integrations, plans, - strava_oauth, - weekly_recap, - whoop_oauth, ) __all__ = [ @@ -21,9 +17,5 @@ "competitions", "dashboard", "health", - "integrations", "plans", - "strava_oauth", - "weekly_recap", - "whoop_oauth", ] diff --git a/api/routers/account.py b/api/routers/account.py index 9f4f0b6..7c400a6 100644 --- a/api/routers/account.py +++ b/api/routers/account.py @@ -1,106 +1,14 @@ -import asyncio -import logging import uuid -from datetime import UTC, datetime -import httpx from fastapi import APIRouter, Depends -from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from api.deps import get_current_user, get_db -from api.models.credentials import StravaCredentials, WhoopCredentials -from api.models.job import AnalysisJob -from api.models.oauth_session import OAuthSession from api.services.account_deletion import delete_account_and_data as perform_account_delete -from api.services.crypto import get_crypto_service -from api.services.integration_connections import mark_integration_disconnected -from api.services.strava_tokens import ensure_valid_access_token as ensure_valid_strava_access_token -from services.strava.oauth import STRAVA_DEAUTHORIZE_URL -from services.whoop.oauth import WHOOP_REVOKE_URL - -logger = logging.getLogger(__name__) router = APIRouter() -def _revoke_whoop_access_sync(*, access_token: str) -> None: - headers = {"Authorization": f"Bearer {access_token}"} - with httpx.Client(timeout=10.0) as client: - res = client.delete(WHOOP_REVOKE_URL, headers=headers) - res.raise_for_status() - - -def _revoke_strava_access_sync(*, access_token: str) -> None: - with httpx.Client(timeout=10.0) as client: - res = client.post(STRAVA_DEAUTHORIZE_URL, data={"access_token": access_token}) - res.raise_for_status() - - -async def _cancel_inflight_analysis_jobs(db: AsyncSession, *, user_id: uuid.UUID) -> None: - await db.execute( - update(AnalysisJob) - .where( - AnalysisJob.user_id == user_id, - AnalysisJob.status.in_(["pending", "running"]), - ) - .values(cancel_requested_at=datetime.now(tz=UTC)) - ) - - -async def _invalidate_oauth_sessions(db: AsyncSession, *, user_id: uuid.UUID, provider: str) -> None: - await db.execute( - delete(OAuthSession).where( - OAuthSession.user_id == user_id, - OAuthSession.provider == provider, - ) - ) - - -@router.post("/strava/disconnect", status_code=202) -async def disconnect_strava( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -) -> dict[str, str]: - creds = await db.execute(select(StravaCredentials).where(StravaCredentials.user_id == user_id)) - row = creds.scalar_one_or_none() - if row: - try: - access_token = await ensure_valid_strava_access_token(db, user_id=user_id) - await asyncio.to_thread(_revoke_strava_access_sync, access_token=access_token) - except Exception: - logger.warning("Failed to revoke Strava token for user %s", user_id, exc_info=True) - await db.delete(row) - await mark_integration_disconnected(db, user_id=user_id, provider="strava", reason="user_initiated") - - await _invalidate_oauth_sessions(db, user_id=user_id, provider="strava") - await _cancel_inflight_analysis_jobs(db, user_id=user_id) - - return {"status": "disconnected"} - - -@router.post("/whoop/disconnect", status_code=202) -async def disconnect_whoop( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -) -> dict[str, str]: - creds = await db.execute(select(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - row = creds.scalar_one_or_none() - if row: - try: - crypto = get_crypto_service() - access_token = crypto.decrypt(row.encrypted_access_token) - await asyncio.to_thread(_revoke_whoop_access_sync, access_token=access_token) - except Exception: - logger.warning("Failed to revoke Whoop token for user %s", user_id, exc_info=True) - await db.delete(row) - await mark_integration_disconnected(db, user_id=user_id, provider="whoop", reason="user_initiated") - await _invalidate_oauth_sessions(db, user_id=user_id, provider="whoop") - await _cancel_inflight_analysis_jobs(db, user_id=user_id) - - return {"status": "disconnected"} - - @router.delete("") async def delete_account_and_data( db: AsyncSession = Depends(get_db), diff --git a/api/routers/analysis.py b/api/routers/analysis.py index bd3d9cf..788c6eb 100644 --- a/api/routers/analysis.py +++ b/api/routers/analysis.py @@ -13,12 +13,15 @@ from api.models.athlete_profile import AthleteProfile from api.models.competition import Competition from api.models.job import AnalysisJob, JobStatus +from api.services.analysis_attempts import get_attempt_started_at +from api.services.analysis_resume import hash_resume_answer, resume_request_matches from api.services.local_readiness import format_llm_provider_key_names, has_llm_provider_key from api.services.local_usage import ensure_plan_generation_available, release_initial_draft_plan_claim +from api.services.plan_generation_lock import lock_owner_plan_generation from api.services.status_messages import ( complete_active_analysis_progress_steps, current_analysis_step, - initial_analysis_progress_steps, + initial_head_coach_progress_steps, normalize_analysis_progress_steps, ) from core.task_timeouts import ( @@ -89,6 +92,19 @@ class AnalysisJobResponse(BaseModel): tokens_used: int | None = None progress_steps: list[ProgressStepResponse] | None = None current_step: str | None = None + interrupt: dict[str, Any] | None = None + + +class ResumeAnalysisRequest(BaseModel): + answer: str = Field(min_length=1, max_length=4_000) + idempotency_key: str = Field(min_length=1, max_length=200) + + @field_validator("answer", "idempotency_key", mode="before") + @classmethod + def normalize_required_strings(cls, value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("Value must be a non-empty string") + return value.strip() class AnalysisResultResponse(BaseModel): @@ -113,13 +129,14 @@ async def run_analysis( ), ) + await lock_owner_plan_generation(db, user_id=user_id) plan_generation_access = await ensure_plan_generation_available(db, user_id=user_id) try: # Create job job = AnalysisJob( user_id=user_id, config=config.model_dump(mode="json"), - progress_steps=initial_analysis_progress_steps(), + progress_steps=initial_head_coach_progress_steps(), ) db.add(job) await db.flush() @@ -129,6 +146,7 @@ async def run_analysis( job_config = { **job.config, "_plan_generation_access_mode": plan_generation_access.mode, + "_workflow_version": "head_coach_v1", } profile_result = await db.execute(select(AthleteProfile).where(AthleteProfile.user_id == user_id)) @@ -187,6 +205,7 @@ async def run_analysis( created_at=job.created_at, progress_steps=[ProgressStepResponse.model_validate(step) for step in (job.progress_steps or [])], current_step=current_analysis_step(job.progress_steps), + interrupt=None, ) @@ -212,7 +231,8 @@ async def get_job_status( # an analysis task time limit is explicitly configured. if job.status == JobStatus.RUNNING.value: now = datetime.now(UTC) - age_seconds = (now - job.created_at).total_seconds() + attempt_started_at = get_attempt_started_at(getattr(job, "config", None), fallback=job.created_at) + age_seconds = (now - attempt_started_at).total_seconds() stale_threshold_seconds = get_analysis_running_job_max_age_seconds() if stale_threshold_seconds is not None and age_seconds > stale_threshold_seconds: task_limit_seconds = get_analysis_task_time_limit_seconds() @@ -239,6 +259,83 @@ async def get_job_status( ProgressStepResponse.model_validate(step) for step in normalize_analysis_progress_steps(job_progress_steps) ], current_step=current_analysis_step(job_progress_steps), + interrupt=(getattr(job, "config", None) or {}).get("_head_coach_interrupt"), + ) + + +@router.post("/{job_id}/resume", status_code=202) +async def resume_analysis( + job_id: uuid.UUID, + request: ResumeAnalysisRequest, + db: AsyncSession = Depends(get_db), + user_id: uuid.UUID = Depends(get_current_user), +) -> AnalysisJobResponse: + row = await db.execute( + select(AnalysisJob) + .where(AnalysisJob.id == job_id, AnalysisJob.user_id == user_id) + .with_for_update() + ) + job = row.scalar_one_or_none() + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + + config = dict(job.config or {}) + previous_resume = config.get("_head_coach_resume") + if isinstance(previous_resume, dict) and previous_resume.get("idempotency_key") == request.idempotency_key: + if not resume_request_matches( + previous_resume, + idempotency_key=request.idempotency_key, + answer=request.answer, + ): + raise HTTPException(status_code=409, detail="Idempotency key was already used with a different answer") + return AnalysisJobResponse( + job_id=str(job.id), + status=job.status, + created_at=job.created_at, + completed_at=job.completed_at, + progress_steps=[ProgressStepResponse.model_validate(step) for step in (job.progress_steps or [])], + current_step=current_analysis_step(job.progress_steps), + interrupt=config.get("_head_coach_interrupt"), + ) + if job.status != JobStatus.AWAITING_INPUT.value: + raise HTTPException(status_code=409, detail="Job is not awaiting input") + if not isinstance(config.get("_head_coach_interrupt"), dict): + raise HTTPException(status_code=409, detail="Job has no resumable clarification") + + config["_head_coach_resume"] = { + "answer": request.answer, + "answer_hash": hash_resume_answer(request.answer), + "idempotency_key": request.idempotency_key, + } + config.pop("_celery_task_id", None) + job.config = config + job.status = JobStatus.PENDING.value + db.add(job) + await db.commit() + + try: + from worker.tasks import run_analysis_task + + run_analysis_task.delay(str(job.id)) + except Exception as exc: + logger.exception("Failed to enqueue resumed analysis job %s", job.id) + job.status = JobStatus.AWAITING_INPUT.value + job.config = { + key: value + for key, value in config.items() + if key != "_head_coach_resume" + } + db.add(job) + await db.commit() + raise HTTPException(status_code=503, detail="Unable to resume analysis job") from exc + + return AnalysisJobResponse( + job_id=str(job.id), + status=job.status, + created_at=job.created_at, + progress_steps=[ProgressStepResponse.model_validate(step) for step in (job.progress_steps or [])], + current_step=current_analysis_step(job.progress_steps), + interrupt=config.get("_head_coach_interrupt"), ) @@ -264,6 +361,9 @@ async def get_job_results( if job.status == JobStatus.RUNNING.value: raise HTTPException(status_code=400, detail="Job is still running") + if job.status == JobStatus.AWAITING_INPUT.value: + raise HTTPException(status_code=400, detail="Job is awaiting input") + if job.status == JobStatus.CANCELLED.value: raise HTTPException(status_code=400, detail="Job was cancelled") diff --git a/api/routers/coach.py b/api/routers/coach.py index 5ec1761..123999b 100644 --- a/api/routers/coach.py +++ b/api/routers/coach.py @@ -16,7 +16,7 @@ router = APIRouter() _COACH_TURN_STREAM_HEARTBEAT_SECONDS = 5.0 -_COACH_TURN_STREAM_TIMEOUT_SECONDS: float | None = None +_COACH_TURN_STREAM_TIMEOUT_SECONDS = 600.0 class CoachTurnUiContext(BaseModel): @@ -30,7 +30,7 @@ class CoachTurnUiContext(BaseModel): class CoachTurnRequest(BaseModel): thread_id: uuid.UUID | None = None - action: str = Field(..., pattern="^(text|proposal_accept|proposal_reject|recap)$") + action: str = Field(..., pattern="^(text|proposal_accept|proposal_reject)$") message: str | None = Field(default=None, min_length=1, max_length=2000) proposal_id: uuid.UUID | None = None reason: str | None = Field(default=None, min_length=1, max_length=500) @@ -170,6 +170,7 @@ async def emit_status(status_payload: dict[str, object]): now = monotonic() if _stream_timeout_reached(now=now, stream_started_at=stream_started_at): yield await _stream_timeout_error(db, turn_task) + yield _sse_event("done") return event, last_status_emit_at = await _next_status_or_heartbeat_event( @@ -190,7 +191,11 @@ async def emit_status(status_payload: dict[str, object]): except Exception as exc: yield await _stream_failure_error(db, turn_task, exc) finally: - yield _sse_event("done") + await _cancel_turn_task(turn_task) + if turn_task.cancelled() and not db.info.get(DB_SKIP_AUTO_COMMIT_FLAG): + await db.rollback() + _disable_auto_commit(db) + yield _sse_event("done") @router.post("/turn") @@ -200,7 +205,7 @@ async def post_turn( db: AsyncSession = Depends(get_db), user_id: uuid.UUID = Depends(get_current_user), ): - if payload.action not in ("text", "recap") or not _wants_sse_response(request): + if payload.action != "text" or not _wants_sse_response(request): return await _post_coach_turn_payload(payload=payload, db=db, user_id=user_id) return StreamingResponse( diff --git a/api/routers/daily.py b/api/routers/daily.py deleted file mode 100644 index d14393c..0000000 --- a/api/routers/daily.py +++ /dev/null @@ -1,420 +0,0 @@ -import logging -import uuid -from datetime import date - -from fastapi import APIRouter, Body, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from api.config import Settings, get_settings -from api.deps import get_current_user, get_db -from api.models.active_weekly_plan import ActiveWeeklyPlan -from api.models.ai_run_cost import AiRunCost -from api.models.coach_proposal import CoachProposal -from api.models.daily_update_run import DailyUpdateRun -from api.services.ai_run_costs import ( - build_ai_run_cost_record, - capture_langsmith_run_costs, - finish_ai_root_trace, - start_ai_root_trace, -) -from api.services.athlete_time import get_athlete_time_context -from api.services.coach_event_store import EVENT_PROPOSAL_CREATED, append_coach_events, get_or_create_coach_thread -from api.services.coach_patch_ops import apply_ops, sanitize_ops -from api.services.connected_coaching import assert_connected_coaching_available -from api.services.daily_sync_sources import extract_daily_sync_sources -from api.services.daily_update_runs import fail_stale_pending_daily_update_run -from api.services.dashboard_state import compose_day_override -from api.services.html_sanitizer import sanitize_html -from api.services.integration_status import load_integrations_status -from api.services.local_usage import ( - LocalUsageContext, - consume_daily_sync, - ensure_daily_sync_available, - get_local_usage_context, - is_usage_safety_bypass_enabled, -) -from api.services.ongoing_tools import build_ongoing_tool_registry -from services.ai.daily.daily_update_agent import generate_daily_update_narrative -from services.ai.daily.schemas import DailyUpdateNarrative -from services.ai.langgraph.schemas.ui_blocks import UiHtmlBlock, UiKpi, UiWeeklyPlan - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -class DailyUpdateRunRequest(BaseModel): - athlete_check_in: str | None = Field( - default=None, - max_length=2000, - description="Optional subjective athlete check-in for today's daily coaching sync.", - ) - - -def _normalize_athlete_check_in(value: str | None) -> str | None: - normalized = (value or "").strip() - return normalized or None - - -def _sanitize_daily_blocks(blocks: list[UiHtmlBlock]) -> list[UiHtmlBlock]: - sanitized: list[UiHtmlBlock] = [] - seen_keys: set[str] = set() - for block in blocks: - key = block.key - if key in seen_keys: - suffix = 1 - while f"{key}-{suffix}" in seen_keys: - suffix += 1 - key = f"{key}-{suffix}" - seen_keys.add(key) - sanitized.append( - block.model_copy( - update={ - "key": key, - "content_html": sanitize_html(block.content_html), - "tone": block.tone if block.variant == "callout" else None, - } - ) - ) - return sanitized - - -def _sanitize_daily_kpis(kpis: list[UiKpi]) -> list[UiKpi]: - sanitized: list[UiKpi] = [] - seen_ids: set[str] = set() - for kpi in kpis: - kpi_id = kpi.kpi_id.strip() or "dashboard-kpi" - if kpi_id in seen_ids: - suffix = 1 - while f"{kpi_id}-{suffix}" in seen_ids: - suffix += 1 - kpi_id = f"{kpi_id}-{suffix}" - seen_ids.add(kpi_id) - sanitized.append(kpi.model_copy(update={"kpi_id": kpi_id})) - return sanitized[:6] - - -def _sanitize_daily_payload(payload: DailyUpdateNarrative) -> DailyUpdateNarrative: - return payload.model_copy( - update={ - "dashboard_kpis": _sanitize_daily_kpis(payload.dashboard_kpis), - "today_focus_blocks": _sanitize_daily_blocks(payload.today_focus_blocks), - "optional_proposal_ops": sanitize_ops(payload.optional_proposal_ops), - } - ) - - -async def _run_daily_update_agent( - *, - db: AsyncSession, - user_id: uuid.UUID, - daily_run: DailyUpdateRun, - target_date_iso: str, - current_plan: UiWeeklyPlan, - athlete_check_in: str | None, -) -> tuple[DailyUpdateNarrative, dict, list[str], dict, AiRunCost]: - ai_trace = start_ai_root_trace( - run_name="daily_update", - feature="daily_update", - user_id=str(user_id), - thread_id=None, - inputs={ - "target_date": target_date_iso, - "trigger_source": "dashboard_widget", - "source_run_id": str(daily_run.id), - "has_athlete_check_in": athlete_check_in is not None, - }, - tags=["agent:daily_update"], - metadata={ - "source_type": "daily_update_run", - "source_id": str(daily_run.id), - "target_date": target_date_iso, - }, - ) - try: - async with build_ongoing_tool_registry(db, user_id=user_id) as tool_registry: - prefetched_recovery_readiness = await tool_registry.get_recovery_readiness_signals(days=7) - sources_used = extract_daily_sync_sources(prefetched_recovery_readiness) - with ai_trace.context_manager(): - narrative = _sanitize_daily_payload( - await generate_daily_update_narrative( - tool_registry=tool_registry, - target_date_iso=target_date_iso, - trigger_source="dashboard_widget", - athlete_check_in=athlete_check_in, - prefetched_recovery_readiness=prefetched_recovery_readiness, - prefetched_weekly_plan=current_plan.model_dump(mode="json"), - invoke_config={ - "run_name": "daily_update", - "tags": [ - "agent:daily_update", - "feature:daily_update", - f"user:{user_id}", - ], - "metadata": { - "user_id": str(user_id), - "target_date": target_date_iso, - "trigger_source": "dashboard_widget", - }, - }, - ) - ) - finish_ai_root_trace( - ai_trace, - outputs={ - "proposal_ops_count": len(narrative.optional_proposal_ops), - "dashboard_kpi_count": len(narrative.dashboard_kpis), - "focus_block_count": len(narrative.today_focus_blocks), - }, - ) - return ( - narrative, - tool_registry.get_observability_snapshot(), - sources_used, - prefetched_recovery_readiness, - build_ai_run_cost_record( - user_id=user_id, - thread_id=None, - feature="daily_update", - source_type="daily_update_run", - source_id=daily_run.id, - run_name="daily_update", - trace_metadata=ai_trace.trace_metadata(), - cost_snapshot=capture_langsmith_run_costs(ai_trace.trace_metadata()), - source_metadata={ - "target_date": target_date_iso, - "trigger_source": "dashboard_widget", - "has_athlete_check_in": athlete_check_in is not None, - }, - ), - ) - except Exception as exc: - finish_ai_root_trace(ai_trace, error=exc) - raise - - -async def _prepare_daily_run( - *, - db: AsyncSession, - user_id: uuid.UUID, - target_date: date, - settings: Settings, -) -> tuple[ActiveWeeklyPlan, LocalUsageContext | None, DailyUpdateRun]: - existing_row = await db.execute( - select(DailyUpdateRun).where( - DailyUpdateRun.user_id == user_id, - DailyUpdateRun.target_date == target_date, - ) - ) - existing_run = existing_row.scalar_one_or_none() - existing_status = str(existing_run.status or "").strip().lower() if existing_run is not None else "" - if existing_status == "pending" and await fail_stale_pending_daily_update_run(db, run=existing_run): - existing_status = str(existing_run.status or "").strip().lower() if existing_run is not None else "" - if existing_status in {"done", "completed"}: - raise HTTPException( - status_code=429, - detail="Daily update already run for today. Check the dashboard for the latest insights.", - ) - if existing_status == "pending": - raise HTTPException(status_code=409, detail="Daily update is already running for today.") - - weekly_row = await db.execute(select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == user_id)) - active_weekly = weekly_row.scalar_one_or_none() - if active_weekly is None: - raise HTTPException(status_code=409, detail="Daily sync requires an active weekly plan.") - - usage_context = None - if not is_usage_safety_bypass_enabled(settings): - usage_context = await get_local_usage_context(db, user_id=user_id) - await ensure_daily_sync_available(db, user_id=user_id, context=usage_context) - - integrations_status = await load_integrations_status(db, user_id=user_id, settings=settings) - assert_connected_coaching_available( - feature_enabled=True, - integrations_status=integrations_status, - locked_message="Daily coaching requires a connected training or recovery source.", - ) - - daily_run = existing_run or DailyUpdateRun( - user_id=user_id, - target_date=target_date, - trigger_source="manual", - ) - daily_run.status = "pending" - daily_run.error_message = None - daily_run.trigger_source = "manual" - daily_run.context_snapshot = None - daily_run.update_payload = None - daily_run.proposal_id = None - db.add(daily_run) - try: - await db.commit() - except IntegrityError as exc: - await db.rollback() - raise HTTPException(status_code=409, detail="Daily update is already running for today.") from exc - await db.refresh(daily_run) - - return active_weekly, usage_context, daily_run - - -async def _create_daily_proposal_if_needed( - *, - db: AsyncSession, - user_id: uuid.UUID, - active_weekly, - current_plan: UiWeeklyPlan, - narrative: DailyUpdateNarrative, - daily_run: DailyUpdateRun, -) -> tuple[uuid.UUID | None, dict | None, uuid.UUID | None]: - if not narrative.optional_proposal_ops: - return None, None, None - - preview_plan, _changed = apply_ops(current_plan, narrative.optional_proposal_ops) - preview_plan_dump = preview_plan.model_dump(mode="json") - - thread = await get_or_create_coach_thread(db, user_id=user_id) - proposal_row = CoachProposal( - user_id=user_id, - thread_id=thread.id, - weekly_plan_version=active_weekly.version, - assistant_message="I've reviewed your latest recovery metrics and recommend the following daily adjustments.", - ops={"ops": [op.model_dump(mode="json") for op in narrative.optional_proposal_ops]}, - origin="daily_update", - status="pending", - ) - db.add(proposal_row) - await db.flush() - daily_run.proposal_id = proposal_row.id - - await append_coach_events( - db, - thread=thread, - items=[ - ( - EVENT_PROPOSAL_CREATED, - "coach", - { - "proposal_id": str(proposal_row.id), - "assistant_message": proposal_row.assistant_message, - "ops": [op.model_dump(mode="json") for op in narrative.optional_proposal_ops], - "base_weekly_plan": current_plan.model_dump(mode="json"), - "preview_weekly_plan": preview_plan_dump, - "origin": proposal_row.origin, - "status": proposal_row.status, - }, - ) - ], - ) - return proposal_row.id, preview_plan_dump, thread.id - - -@router.post("/run") -async def run_daily_update( - request: DailyUpdateRunRequest | None = Body(default=None), - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -): - settings = get_settings() - athlete_time = await get_athlete_time_context(db, user_id=user_id) - target_date = athlete_time.today_local_date - target_date_iso = target_date.isoformat() - athlete_check_in = _normalize_athlete_check_in(request.athlete_check_in if request else None) - active_weekly, usage_context, daily_run = await _prepare_daily_run( - db=db, - user_id=user_id, - target_date=target_date, - settings=settings, - ) - - current_plan = UiWeeklyPlan.model_validate(active_weekly.plan_data) - thread_id: uuid.UUID | None = None - ai_cost_record = None - - try: - ( - narrative, - observability, - sources_used, - prefetched_recovery_readiness, - ai_cost_record, - ) = await _run_daily_update_agent( - db=db, - user_id=user_id, - daily_run=daily_run, - target_date_iso=target_date_iso, - current_plan=current_plan, - athlete_check_in=athlete_check_in, - ) - db.add(ai_cost_record) - - proposal_id, preview_plan_dump, thread_id = await _create_daily_proposal_if_needed( - db=db, - user_id=user_id, - active_weekly=active_weekly, - current_plan=current_plan, - narrative=narrative, - daily_run=daily_run, - ) - - day_override = compose_day_override( - weekly_plan=current_plan, - target_date_iso=target_date_iso, - today_focus_blocks=narrative.today_focus_blocks, - ) - - daily_run.status = "completed" - if not is_usage_safety_bypass_enabled(settings): - await consume_daily_sync( - db, - user_id=user_id, - source_id=str(daily_run.id), - context=usage_context, - ) - daily_run.context_snapshot = { - "athlete_time": { - "timezone": athlete_time.timezone, - "timezone_source": athlete_time.timezone_source, - "today_local_date": target_date_iso, - "now_local_iso": athlete_time.now_local_iso, - }, - "prefetched_recovery_readiness": prefetched_recovery_readiness, - "athlete_check_in": athlete_check_in, - "tool_observability": observability, - } - daily_run.update_payload = narrative.model_dump(mode="json") - db.add(daily_run) - await db.commit() - await db.refresh(daily_run) - - return { - "status": "success", - "run_id": str(daily_run.id), - "sources_used": sources_used, - "proposal_id": str(proposal_id) if proposal_id else None, - "thread_id": str(thread_id) if thread_id else None, - "preview_weekly_plan": preview_plan_dump, - "narrative": daily_run.update_payload, - "today_override": day_override.model_dump(mode="json") if day_override is not None else None, - } - except HTTPException as exc: - await db.rollback() - daily_run.status = "failed" - daily_run.error_message = str(exc.detail) - db.add(daily_run) - if ai_cost_record is not None: - db.add(ai_cost_record) - await db.commit() - raise - except Exception as exc: - await db.rollback() - daily_run.status = "failed" - daily_run.error_message = str(exc) - db.add(daily_run) - if ai_cost_record is not None: - db.add(ai_cost_record) - await db.commit() - logger.exception("Daily update failed for user %s", user_id) - raise HTTPException(status_code=500, detail="Failed to generate daily update.") from exc diff --git a/api/routers/integrations.py b/api/routers/integrations.py deleted file mode 100644 index 2492426..0000000 --- a/api/routers/integrations.py +++ /dev/null @@ -1,22 +0,0 @@ -import uuid - -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from api.config import get_settings -from api.deps import get_current_user, get_db -from api.services.integration_status import IntegrationsStatus, load_integrations_status - -router = APIRouter() - - -@router.get("/status") -async def get_integrations_status( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -) -> IntegrationsStatus: - return await load_integrations_status( - db, - user_id=user_id, - settings=get_settings(), - ) diff --git a/api/routers/strava_oauth.py b/api/routers/strava_oauth.py deleted file mode 100644 index 665f15e..0000000 --- a/api/routers/strava_oauth.py +++ /dev/null @@ -1,200 +0,0 @@ -import asyncio -import secrets -import uuid -from datetime import UTC, datetime, timedelta - -import httpx -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import RedirectResponse -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from api.config import get_settings -from api.deps import get_current_user, get_db -from api.models.credentials import StravaCredentials -from api.models.oauth_session import OAuthSession -from api.services.crypto import get_crypto_service -from api.services.integration_connections import mark_integration_connected -from services.strava.oauth import build_authorize_url, compute_expires_at, exchange_code_for_tokens - -router = APIRouter() - -_DEFAULT_STRAVA_SCOPES: tuple[str, ...] = ("activity:read_all",) - - -def _strava_enabled_or_403(): - settings = get_settings() - if not settings.strava_oauth_enabled: - raise HTTPException(status_code=403, detail="Strava OAuth is not enabled yet.") - if not settings.strava_oauth_client_id or not settings.strava_oauth_client_secret: - raise HTTPException(status_code=500, detail="Strava OAuth is not configured (missing client_id/client_secret).") - if not settings.strava_oauth_redirect_uri: - raise HTTPException(status_code=500, detail="Strava OAuth is not configured (missing redirect_uri).") - return settings - - -def _extract_athlete_id(token_payload: dict) -> int | None: - athlete = token_payload.get("athlete") - if not isinstance(athlete, dict): - return None - raw_athlete_id = athlete.get("id") - try: - return int(raw_athlete_id) if raw_athlete_id is not None else None - except (TypeError, ValueError): - return None - - -async def _load_strava_oauth_session( - db: AsyncSession, - *, - state: str, - now: datetime, -) -> OAuthSession: - session_row = await db.execute( - select(OAuthSession) - .where( - OAuthSession.provider == "strava", - OAuthSession.state == state, - ) - .with_for_update() - ) - session = session_row.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=400, detail="Invalid OAuth state.") - if session.used_at is not None: - raise HTTPException(status_code=409, detail="OAuth state already used.") - if session.expires_at.astimezone(UTC) <= now: - raise HTTPException(status_code=400, detail="OAuth state expired. Please restart the connection flow.") - return session - - -async def _upsert_strava_credentials( - db: AsyncSession, - *, - user_id: uuid.UUID, - encrypted_access_token: bytes, - encrypted_refresh_token: bytes | None, - expires_at: datetime, - scope: str, - strava_athlete_id: int | None, -): - existing = await db.execute(select(StravaCredentials).where(StravaCredentials.user_id == user_id)) - creds = existing.scalar_one_or_none() - if creds is None: - db.add( - StravaCredentials( - user_id=user_id, - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=str(scope or ""), - strava_athlete_id=strava_athlete_id, - ) - ) - return - - await db.execute( - update(StravaCredentials) - .where(StravaCredentials.user_id == user_id) - .values( - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=str(scope or ""), - strava_athlete_id=strava_athlete_id, - ) - ) - - -@router.get("/start") -async def strava_oauth_start( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -) -> RedirectResponse: - settings = _strava_enabled_or_403() - - now = datetime.now(UTC) - state = secrets.token_urlsafe(32) - expires_at = now + timedelta(minutes=10) - - db.add( - OAuthSession( - user_id=user_id, - provider="strava", - state=state, - code_verifier=None, - expires_at=expires_at, - used_at=None, - ) - ) - await db.flush() - - url = build_authorize_url( - client_id=settings.strava_oauth_client_id, - redirect_uri=settings.strava_oauth_redirect_uri, - state=state, - scope=",".join(_DEFAULT_STRAVA_SCOPES), - ) - return RedirectResponse(url=url, status_code=302) - - -@router.get("/callback") -async def strava_oauth_callback( - code: str | None = None, - state: str | None = None, - scope: str | None = None, - error: str | None = None, - db: AsyncSession = Depends(get_db), -) -> dict[str, str]: - settings = _strava_enabled_or_403() - - if error: - raise HTTPException(status_code=400, detail=f"Strava OAuth error: {error}") - if not code or not state: - raise HTTPException(status_code=400, detail="Missing OAuth parameters (code/state).") - - now = datetime.now(UTC) - session = await _load_strava_oauth_session(db, state=state, now=now) - - session.used_at = now - db.add(session) - await db.flush() - - try: - token_payload = await asyncio.to_thread( - exchange_code_for_tokens, - code=code, - client_id=settings.strava_oauth_client_id, - client_secret=settings.strava_oauth_client_secret, - ) - except httpx.HTTPError as exc: - raise HTTPException(status_code=400, detail="Strava token exchange failed. Please try again.") from exc - - access_token = token_payload.get("access_token") - refresh_token = token_payload.get("refresh_token") - expires_at = compute_expires_at( - now=now, - expires_at=token_payload.get("expires_at"), - expires_in=token_payload.get("expires_in"), - ) - accepted_scope = str(scope or "") - strava_athlete_id = _extract_athlete_id(token_payload) - if not isinstance(access_token, str) or not access_token.strip(): - raise HTTPException(status_code=400, detail="Strava token exchange did not return an access token.") - - crypto = get_crypto_service() - encrypted_access_token = crypto.encrypt(access_token) - encrypted_refresh_token = crypto.encrypt(refresh_token) if isinstance(refresh_token, str) and refresh_token else None - - await _upsert_strava_credentials( - db, - user_id=session.user_id, - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=accepted_scope, - strava_athlete_id=strava_athlete_id, - ) - await mark_integration_connected(db, user_id=session.user_id, provider="strava", connected_at=now) - - return {"status": "connected"} diff --git a/api/routers/weekly_recap.py b/api/routers/weekly_recap.py deleted file mode 100644 index fe7e8a2..0000000 --- a/api/routers/weekly_recap.py +++ /dev/null @@ -1,17 +0,0 @@ -import uuid - -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from api.deps import get_current_user, get_db -from api.services.recap import get_latest_weekly_recap_report - -router = APIRouter() - - -@router.get("/latest") -async def get_latest_recap( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -): - return await get_latest_weekly_recap_report(db, user_id=user_id) diff --git a/api/routers/whoop_oauth.py b/api/routers/whoop_oauth.py deleted file mode 100644 index 4c685a6..0000000 --- a/api/routers/whoop_oauth.py +++ /dev/null @@ -1,223 +0,0 @@ -import asyncio -import secrets -import uuid -from datetime import UTC, datetime, timedelta - -import httpx -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import RedirectResponse -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from api.config import get_settings -from api.deps import get_current_user, get_db -from api.models.credentials import WhoopCredentials -from api.models.oauth_session import OAuthSession -from api.services.crypto import get_crypto_service -from api.services.integration_connections import mark_integration_connected -from services.whoop.oauth import ( - WHOOP_PROFILE_URL, - build_authorize_url, - compute_expires_at, - exchange_code_for_tokens, -) - -router = APIRouter() - -# Keep scopes explicit and minimal; add more only when the product needs it. -_DEFAULT_WHOOP_SCOPES: tuple[str, ...] = ( - "read:recovery", - "read:cycles", - "read:workout", - "read:sleep", - "read:profile", - "read:body_measurement", - "offline", -) - - -def _whoop_enabled_or_403(): - settings = get_settings() - if not settings.whoop_oauth_enabled: - raise HTTPException(status_code=403, detail="WHOOP OAuth is not enabled yet.") - if not settings.whoop_oauth_client_id or not settings.whoop_oauth_client_secret: - raise HTTPException(status_code=500, detail="WHOOP OAuth is not configured (missing client_id/client_secret).") - if not settings.whoop_oauth_redirect_uri: - raise HTTPException(status_code=500, detail="WHOOP OAuth is not configured (missing redirect_uri).") - return settings - - -def _fetch_whoop_user_id(*, access_token: str) -> int | None: - headers = {"Authorization": f"Bearer {access_token}"} - with httpx.Client(timeout=10.0) as client: - res = client.get(WHOOP_PROFILE_URL, headers=headers) - res.raise_for_status() - payload = res.json() - raw_user_id = payload.get("user_id") if isinstance(payload, dict) else None - try: - return int(raw_user_id) if raw_user_id is not None else None - except (TypeError, ValueError): - return None - - -async def _load_whoop_oauth_session( - db: AsyncSession, - *, - state: str, - now: datetime, -) -> OAuthSession: - session_row = await db.execute( - select(OAuthSession) - .where( - OAuthSession.provider == "whoop", - OAuthSession.state == state, - ) - .with_for_update() - ) - session = session_row.scalar_one_or_none() - if session is None: - raise HTTPException(status_code=400, detail="Invalid OAuth state.") - if session.used_at is not None: - raise HTTPException(status_code=409, detail="OAuth state already used.") - if session.expires_at.astimezone(UTC) <= now: - raise HTTPException(status_code=400, detail="OAuth state expired. Please restart the connection flow.") - return session - - -async def _upsert_whoop_credentials( - db: AsyncSession, - *, - user_id: uuid.UUID, - encrypted_access_token: bytes, - encrypted_refresh_token: bytes | None, - expires_at: datetime, - scope: str, - whoop_user_id: int | None, -): - existing = await db.execute(select(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - creds = existing.scalar_one_or_none() - if creds is None: - db.add( - WhoopCredentials( - user_id=user_id, - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=str(scope or ""), - whoop_user_id=whoop_user_id, - ) - ) - return - - await db.execute( - update(WhoopCredentials) - .where(WhoopCredentials.user_id == user_id) - .values( - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=str(scope or ""), - whoop_user_id=whoop_user_id, - ) - ) - - -@router.get("/start") -async def whoop_oauth_start( - db: AsyncSession = Depends(get_db), - user_id: uuid.UUID = Depends(get_current_user), -) -> RedirectResponse: - settings = _whoop_enabled_or_403() - - now = datetime.now(UTC) - state = secrets.token_urlsafe(32) - expires_at = now + timedelta(minutes=10) - - db.add( - OAuthSession( - user_id=user_id, - provider="whoop", - state=state, - code_verifier=None, - expires_at=expires_at, - used_at=None, - ) - ) - await db.flush() - - url = build_authorize_url( - client_id=settings.whoop_oauth_client_id, - redirect_uri=settings.whoop_oauth_redirect_uri, - state=state, - scope=" ".join(_DEFAULT_WHOOP_SCOPES), - ) - return RedirectResponse(url=url, status_code=302) - - -@router.get("/callback") -async def whoop_oauth_callback( - code: str | None = None, - state: str | None = None, - error: str | None = None, - error_description: str | None = None, - db: AsyncSession = Depends(get_db), -) -> dict[str, str]: - settings = _whoop_enabled_or_403() - - if error: - description = error_description.strip() if isinstance(error_description, str) else "" - detail = f"WHOOP OAuth error: {error}" + (f" ({description})" if description else "") - raise HTTPException(status_code=400, detail=detail) - if not code or not state: - raise HTTPException(status_code=400, detail="Missing OAuth parameters (code/state).") - - now = datetime.now(UTC) - session = await _load_whoop_oauth_session(db, state=state, now=now) - - session.used_at = now - db.add(session) - await db.flush() - - try: - token_payload = await asyncio.to_thread( - exchange_code_for_tokens, - code=code, - client_id=settings.whoop_oauth_client_id, - client_secret=settings.whoop_oauth_client_secret, - redirect_uri=settings.whoop_oauth_redirect_uri, - ) - except httpx.HTTPError as exc: - raise HTTPException(status_code=400, detail="WHOOP token exchange failed. Please try again.") from exc - - access_token = token_payload.get("access_token") - refresh_token = token_payload.get("refresh_token") - expires_in = token_payload.get("expires_in") - scope = token_payload.get("scope") or "" - if not isinstance(access_token, str) or not access_token.strip(): - raise HTTPException(status_code=400, detail="WHOOP token exchange did not return an access token.") - - expires_at = compute_expires_at(now=now, expires_in=expires_in) - - whoop_user_id: int | None = None - try: - whoop_user_id = await asyncio.to_thread(_fetch_whoop_user_id, access_token=access_token) - except Exception: - # Optional enrichment only; do not fail the OAuth flow on profile lookup. - whoop_user_id = None - - crypto = get_crypto_service() - encrypted_access_token = crypto.encrypt(access_token) - encrypted_refresh_token = crypto.encrypt(refresh_token) if isinstance(refresh_token, str) and refresh_token else None - - await _upsert_whoop_credentials( - db, - user_id=session.user_id, - encrypted_access_token=encrypted_access_token, - encrypted_refresh_token=encrypted_refresh_token, - expires_at=expires_at, - scope=str(scope or ""), - whoop_user_id=whoop_user_id, - ) - await mark_integration_connected(db, user_id=session.user_id, provider="whoop", connected_at=now) - - return {"status": "connected"} diff --git a/api/services/account_deletion.py b/api/services/account_deletion.py index 58e34ac..a4a019c 100644 --- a/api/services/account_deletion.py +++ b/api/services/account_deletion.py @@ -1,12 +1,8 @@ from __future__ import annotations -import asyncio -import logging import uuid -from dataclasses import dataclass from types import SimpleNamespace -import httpx from fastapi import HTTPException from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession @@ -35,17 +31,7 @@ from api.models.oauth_session import OAuthSession from api.models.user import User from api.models.weekly_recap_run import WeeklyRecapRun -from api.services.crypto import get_crypto_service -from services.strava.oauth import STRAVA_DEAUTHORIZE_URL -from services.whoop.oauth import WHOOP_REVOKE_URL - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class _ProviderRevocation: - provider: str - access_token: str +from services.ai.head_coach.checkpointing import delete_owner_checkpoints def _local_reset_success_payload() -> dict[str, str]: @@ -69,19 +55,6 @@ def _local_data_delete_disabled_payload() -> dict[str, object]: } -def _revoke_whoop_access_sync(*, access_token: str): - headers = {"Authorization": f"Bearer {access_token}"} - with httpx.Client(timeout=10.0) as client: - response = client.delete(WHOOP_REVOKE_URL, headers=headers) - response.raise_for_status() - - -def _revoke_strava_access_sync(*, access_token: str): - with httpx.Client(timeout=10.0) as client: - response = client.post(STRAVA_DEAUTHORIZE_URL, data={"access_token": access_token}) - response.raise_for_status() - - async def _load_user_for_deletion(db: AsyncSession, *, user_id: uuid.UUID) -> SimpleNamespace: row = await db.execute(select(User.id).where(User.id == user_id)) user = row.one_or_none() @@ -90,57 +63,8 @@ async def _load_user_for_deletion(db: AsyncSession, *, user_id: uuid.UUID) -> Si return SimpleNamespace(id=user.id) -async def _collect_provider_revocations(db: AsyncSession, *, user_id: uuid.UUID) -> list[_ProviderRevocation]: - revocations: list[_ProviderRevocation] = [] - - try: - whoop_creds = await db.execute(select(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - whoop_row = whoop_creds.scalar_one_or_none() - if whoop_row is not None: - crypto = get_crypto_service() - revocations.append( - _ProviderRevocation( - provider="whoop", - access_token=crypto.decrypt(whoop_row.encrypted_access_token), - ) - ) - except Exception: - logger.warning("Failed to prepare Whoop token revocation during account delete for user %s", user_id, exc_info=True) - - try: - strava_creds = await db.execute(select(StravaCredentials).where(StravaCredentials.user_id == user_id)) - strava_row = strava_creds.scalar_one_or_none() - if strava_row is not None: - crypto = get_crypto_service() - revocations.append( - _ProviderRevocation( - provider="strava", - access_token=crypto.decrypt(strava_row.encrypted_access_token), - ) - ) - except Exception: - logger.warning("Failed to prepare Strava token revocation during account delete for user %s", user_id, exc_info=True) - - return revocations - - -async def _revoke_provider_access(revocations: list[_ProviderRevocation], *, user_id: uuid.UUID): - for revocation in revocations: - try: - if revocation.provider == "whoop": - await asyncio.to_thread(_revoke_whoop_access_sync, access_token=revocation.access_token) - elif revocation.provider == "strava": - await asyncio.to_thread(_revoke_strava_access_sync, access_token=revocation.access_token) - except Exception: - logger.warning( - "Failed to revoke %s token after account delete for user %s", - revocation.provider, - user_id, - exc_info=True, - ) - - async def _delete_local_account_records(db: AsyncSession, *, user_id: uuid.UUID, delete_user: bool = True): + await delete_owner_checkpoints(db, owner_id=user_id) await db.execute( delete(CoachMessage).where( CoachMessage.conversation_id.in_(select(CoachConversation.id).where(CoachConversation.user_id == user_id)) @@ -187,11 +111,8 @@ async def delete_account_and_data(db: AsyncSession, *, user_id: uuid.UUID) -> di raise HTTPException(status_code=403, detail=_local_data_delete_disabled_payload()) await _load_user_for_deletion(db, user_id=user_id) - provider_revocations = await _collect_provider_revocations(db, user_id=user_id) - db.info[DB_SKIP_AUTO_COMMIT_FLAG] = True await _delete_local_account_records(db, user_id=user_id, delete_user=False) await db.commit() - await _revoke_provider_access(provider_revocations, user_id=user_id) return _local_reset_success_payload() diff --git a/api/services/active_plans.py b/api/services/active_plans.py index dbfb20c..020cbeb 100644 --- a/api/services/active_plans.py +++ b/api/services/active_plans.py @@ -6,6 +6,7 @@ from api.models.active_analysis import ActiveAnalysis from api.models.active_season_plan import ActiveSeasonPlan from api.models.active_weekly_plan import ActiveWeeklyPlan +from services.ai.head_coach.artifacts import ExecutionPlanArtifactV3 async def get_active_analysis(db: AsyncSession, *, user_id: uuid.UUID) -> dict | None: @@ -73,21 +74,7 @@ async def toggle_day_completion(db: AsyncSession, *, user_id: uuid.UUID, day_id: if not active or not active.plan_data: raise ValueError("No active weekly plan found") - plan_data = dict(active.plan_data) - weeks = plan_data.get("weeks", []) - - found = False - for week in weeks: - for day in week.get("days", []): - if day.get("day_id") == day_id: - day["is_completed"] = is_completed - found = True - break - if found: - break - - if not found: - raise ValueError(f"Day ID {day_id} not found in active weekly plan") + plan_data = set_day_completion(dict(active.plan_data), day_id=day_id, is_completed=is_completed) active.plan_data = plan_data # SQLAlchemy requires flagging JSONB mutations manually @@ -105,3 +92,22 @@ async def toggle_day_completion(db: AsyncSession, *, user_id: uuid.UUID, day_id: "source_job_id": str(active.source_job_id), "updated_at": active.updated_at.isoformat(), } + + +def set_day_completion(plan_data: dict, *, day_id: str, is_completed: bool) -> dict: + schema_version = plan_data.get("schema_version", 1) + if schema_version not in {1, 3}: + raise ValueError(f"Unsupported weekly-plan schema version: {schema_version}") + if schema_version == 3: + validated = ExecutionPlanArtifactV3.model_validate(plan_data) + plan_data = validated.model_dump(mode="json") + + for week in plan_data.get("weeks", []): + for day in week.get("days", []): + if day.get("day_id") != day_id: + continue + day["is_completed"] = is_completed + if schema_version == 3: + return ExecutionPlanArtifactV3.model_validate(plan_data).model_dump(mode="json") + return plan_data + raise ValueError(f"Day ID {day_id} not found in active weekly plan") diff --git a/api/services/analysis_attempts.py b/api/services/analysis_attempts.py new file mode 100644 index 0000000..5a700a2 --- /dev/null +++ b/api/services/analysis_attempts.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +ATTEMPT_STARTED_AT_KEY = "_head_coach_attempt_started_at" + + +def with_attempt_started_at(config: dict[str, Any], *, started_at: datetime) -> dict[str, Any]: + value = started_at if started_at.tzinfo is not None else started_at.replace(tzinfo=UTC) + return {**config, ATTEMPT_STARTED_AT_KEY: value.astimezone(UTC).isoformat()} + + +def get_attempt_started_at(config: object, *, fallback: datetime) -> datetime: + if isinstance(config, dict): + raw_value = config.get(ATTEMPT_STARTED_AT_KEY) + if isinstance(raw_value, str): + try: + parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + parsed = None + if parsed is not None and parsed.tzinfo is not None: + return parsed.astimezone(UTC) + return fallback if fallback.tzinfo is not None else fallback.replace(tzinfo=UTC) diff --git a/api/services/analysis_resume.py b/api/services/analysis_resume.py new file mode 100644 index 0000000..7b43344 --- /dev/null +++ b/api/services/analysis_resume.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import hashlib + + +def hash_resume_answer(answer: str) -> str: + return hashlib.sha256(answer.encode("utf-8")).hexdigest() + + +def resume_request_matches(receipt: object, *, idempotency_key: str, answer: str) -> bool: + if not isinstance(receipt, dict) or receipt.get("idempotency_key") != idempotency_key: + return False + expected_hash = receipt.get("answer_hash") + if isinstance(expected_hash, str): + return expected_hash == hash_resume_answer(answer) + stored_answer = receipt.get("answer") + return isinstance(stored_answer, str) and stored_answer == answer + + +def terminal_resume_receipt(receipt: object) -> dict[str, str] | None: + if not isinstance(receipt, dict): + return None + idempotency_key = receipt.get("idempotency_key") + answer_hash = receipt.get("answer_hash") + answer = receipt.get("answer") + if not isinstance(idempotency_key, str): + return None + if not isinstance(answer_hash, str) and isinstance(answer, str): + answer_hash = hash_resume_answer(answer) + if not isinstance(answer_hash, str): + return None + return {"idempotency_key": idempotency_key, "answer_hash": answer_hash} diff --git a/api/services/coach_context.py b/api/services/coach_context.py index 49fc4ed..8d1d93a 100644 --- a/api/services/coach_context.py +++ b/api/services/coach_context.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging import uuid from datetime import UTC, datetime @@ -9,13 +8,15 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from api.models.active_analysis import ActiveAnalysis from api.models.coach_event import CoachEvent from api.models.coach_thread import CoachThread from api.models.user import User from api.services.coach_event_store import EVENT_TOOL_TRACE, safe_event_payload from api.services.coach_memory_metadata import derive_memory_freshness, extract_transient_state_notes from api.services.ongoing_tools import OngoingToolRegistry, build_ongoing_tool_registry, nearest_competition_days +from services.ai.head_coach.run_profiles import RunProfileName, get_run_profile +from services.ai.head_coach.runtime_context import build_head_coach_brief +from services.ai.head_coach.schemas import HeadCoachBrief logger = logging.getLogger(__name__) @@ -33,6 +34,22 @@ } +def package_head_coach_brief( + *, + owner_id: str, + run_id: str, + profile_name: RunProfileName | str, + context_pack: dict, +) -> HeadCoachBrief: + """Wrap the existing complete context pack in the shared serializable contract.""" + return build_head_coach_brief( + owner_id=owner_id, + run_id=run_id, + profile=get_run_profile(profile_name), + context_pack=context_pack, + ) + + def _behavior_patterns(events: list[CoachEvent]) -> dict: rejected = 0 accepted = 0 @@ -53,6 +70,7 @@ def _behavior_patterns(events: list[CoachEvent]) -> dict: "athlete_message_count": athlete_messages, } + def _contains_salient_text(message: str) -> bool: lowered = message.lower() return any(keyword in lowered for keyword in _SALIENT_KEYWORDS) @@ -62,23 +80,6 @@ def is_salient_event(*, message: str, proposal_changed: bool) -> bool: return proposal_changed or _contains_salient_text(message) -def _build_full_run_hints(active_analysis: ActiveAnalysis | None, now: datetime) -> dict: - if active_analysis is None: - return { - "has_analysis": False, - "age_days": None, - "analysis_version": None, - "updated_at": None, - } - age_days = (now - active_analysis.updated_at.astimezone(UTC)).days - return { - "has_analysis": True, - "age_days": age_days, - "analysis_version": active_analysis.version, - "updated_at": active_analysis.updated_at.astimezone(UTC).isoformat(), - } - - def _serialize_plan_scalar(value: object) -> str | int | bool | None: if value is None or isinstance(value, (str, int, bool)): return value @@ -87,6 +88,44 @@ def _serialize_plan_scalar(value: object) -> str | int | bool | None: return str(value) +def _summarize_v1_day(raw_day: dict) -> dict[str, object]: + return { + "day_id": _serialize_plan_scalar(raw_day.get("day_id")), + "date": _serialize_plan_scalar(raw_day.get("date")), + "day_label": _serialize_plan_scalar(raw_day.get("day_label")), + "workout_title": _serialize_plan_scalar(raw_day.get("workout_title")), + "focus_type": _serialize_plan_scalar(raw_day.get("focus_type")), + "estimated_duration_min": raw_day.get("estimated_duration_min"), + "estimated_intensity": _serialize_plan_scalar(raw_day.get("estimated_intensity")), + "is_completed": raw_day.get("is_completed"), + } + + +def _summarize_v3_day(raw_day: dict) -> dict[str, object]: + raw_sessions = raw_day.get("sessions") + return { + "day_id": _serialize_plan_scalar(raw_day.get("day_id")), + "date": _serialize_plan_scalar(raw_day.get("date")), + "label": _serialize_plan_scalar(raw_day.get("label")), + "focus_type": _serialize_plan_scalar(raw_day.get("focus_type")), + "total_duration_min": raw_day.get("total_duration_min"), + "intensity": _serialize_plan_scalar(raw_day.get("intensity")), + "is_completed": raw_day.get("is_completed"), + "sessions": [ + { + "session_id": _serialize_plan_scalar(raw_session.get("session_id")), + "title": _serialize_plan_scalar(raw_session.get("title")), + "duration_min": raw_session.get("duration_min"), + "intensity": _serialize_plan_scalar(raw_session.get("intensity")), + } + for raw_session in raw_sessions + if isinstance(raw_session, dict) + ] + if isinstance(raw_sessions, list) + else [], + } + + def _summarize_current_weekly_plan_identity(weekly_plan: dict | None) -> dict | None: if not isinstance(weekly_plan, dict): return None @@ -95,6 +134,10 @@ def _summarize_current_weekly_plan_identity(weekly_plan: dict | None) -> dict | if not isinstance(raw_weeks, list) or not raw_weeks: return None + schema_version = weekly_plan.get("schema_version", 1) + if schema_version not in {1, 3}: + return None + weeks: list[dict[str, object]] = [] for raw_week in raw_weeks: if not isinstance(raw_week, dict): @@ -105,22 +148,14 @@ def _summarize_current_weekly_plan_identity(weekly_plan: dict | None) -> dict | for raw_day in raw_days: if not isinstance(raw_day, dict): continue - summarized_days.append( - { - "day_id": _serialize_plan_scalar(raw_day.get("day_id")), - "date": _serialize_plan_scalar(raw_day.get("date")), - "day_label": _serialize_plan_scalar(raw_day.get("day_label")), - "workout_title": _serialize_plan_scalar(raw_day.get("workout_title")), - "focus_type": _serialize_plan_scalar(raw_day.get("focus_type")), - "estimated_duration_min": raw_day.get("estimated_duration_min"), - "estimated_intensity": _serialize_plan_scalar(raw_day.get("estimated_intensity")), - "is_completed": raw_day.get("is_completed"), - } - ) + summarize_day = _summarize_v3_day if schema_version == 3 else _summarize_v1_day + summarized_days.append(summarize_day(raw_day)) weeks.append( { "week_id": _serialize_plan_scalar(raw_week.get("week_id")), - "week_label": _serialize_plan_scalar(raw_week.get("week_label")), + "title" if schema_version == 3 else "week_label": _serialize_plan_scalar( + raw_week.get("title" if schema_version == 3 else "week_label") + ), "start_date": _serialize_plan_scalar(raw_week.get("start_date")), "end_date": _serialize_plan_scalar(raw_week.get("end_date")), "days": summarized_days, @@ -131,6 +166,7 @@ def _summarize_current_weekly_plan_identity(weekly_plan: dict | None) -> dict | return None return { + "schema_version": schema_version, "plan_id": _serialize_plan_scalar(weekly_plan.get("plan_id")), "version": weekly_plan.get("version"), "updated_at": _serialize_plan_scalar(weekly_plan.get("updated_at")), @@ -233,38 +269,17 @@ async def _build_turn_context_with_registry( now = datetime.now(UTC) today = now.date() tool_snapshot = tool_registry.get_observability_snapshot() - evidence_profile = tool_snapshot.get("evidence_profile") if isinstance(tool_snapshot, dict) else None memory_summary, athlete_model = await _load_user_long_term_memory(db, user_id=user_id) memory_updated_at, memory_age_days = derive_memory_freshness(athlete_model, now=now) transient_state_notes = extract_transient_state_notes(athlete_model) - analysis_row = await db.execute(select(ActiveAnalysis).where(ActiveAnalysis.user_id == user_id)) - full_run_hints = _build_full_run_hints(analysis_row.scalar_one_or_none(), now) - - training_snapshot: dict | None = None - expert_analysis_summary: dict | None = None - competition_proximity_days: int | None = None - upcoming_competitions: list[dict] = [] - current_weekly_plan_identity: dict | None = None - - if mode == "proactive_eval": - training_snapshot, expert_analysis_summary, upcoming_competitions, current_weekly_plan = await asyncio.gather( - tool_registry.get_training_snapshot(), - tool_registry.get_expert_analysis_summary(), - tool_registry.get_upcoming_competitions(), - tool_registry.get_current_weekly_plan(), - ) - if isinstance(training_snapshot, dict): - competition_proximity_days = training_snapshot.get("competition_proximity_days") - snapshot_evidence_profile = training_snapshot.get("evidence_profile") - if isinstance(snapshot_evidence_profile, dict): - evidence_profile = snapshot_evidence_profile - else: - upcoming_competitions, current_weekly_plan = await asyncio.gather( - tool_registry.get_upcoming_competitions(), - tool_registry.get_current_weekly_plan(), - ) - competition_proximity_days = nearest_competition_days(upcoming_competitions, today) + # The registry shares this request's AsyncSession. Keep the prefetches + # sequential: AsyncSession is a mutable transaction and cannot service + # concurrent execute() calls safely. + upcoming_competitions = await tool_registry.get_upcoming_competitions() + current_weekly_plan = await tool_registry.get_current_weekly_plan() + current_season_plan = await tool_registry.get_current_season_plan() + competition_proximity_days = nearest_competition_days(upcoming_competitions, today) current_weekly_plan_identity = _summarize_current_weekly_plan_identity(current_weekly_plan) @@ -291,11 +306,8 @@ async def _build_turn_context_with_registry( "behavior_context": _behavior_patterns(recent_events), }, "tool_budget": _build_tool_budget(recent_events), - "evidence_profile": evidence_profile, - "training_snapshot": training_snapshot, - "expert_analysis_summary": expert_analysis_summary, "current_weekly_plan_identity": current_weekly_plan_identity, - "full_run_hints": full_run_hints, + "current_season_plan": current_season_plan, "recent_events": _serialize_recent_conversation_events(recent_events), "recent_tool_results": _serialize_recent_tool_results(recent_events), "tool_observability": tool_snapshot, @@ -325,9 +337,7 @@ async def build_turn_context( ui_context=ui_context, ) - async with build_ongoing_tool_registry( - db, user_id=user_id, require_training_provider=False - ) as registry: + async with build_ongoing_tool_registry(db, user_id=user_id) as registry: return await _build_turn_context_with_registry( db, user_id=user_id, diff --git a/api/services/coach_event_store.py b/api/services/coach_event_store.py index 17b5194..b8763cf 100644 --- a/api/services/coach_event_store.py +++ b/api/services/coach_event_store.py @@ -12,10 +12,6 @@ from api.models.coach_thread import CoachThread from api.models.weekly_recap_run import WeeklyRecapRun from api.services.coach_quota import get_coach_weekly_quota -from api.services.connected_coaching import resolve_connected_coaching_gate -from api.services.full_run_policy import evaluate_weekly_recap_availability -from api.services.integration_status import IntegrationsStatus, load_integrations_status -from api.services.local_usage import get_local_usage_context, has_weekly_recap_feature_access from core.recap_schedule import compute_recap_week_anchor_utc EVENT_USER_MESSAGE = "user_message" @@ -32,24 +28,6 @@ THREAD_STATUS_ARCHIVED = "archived" -def resolve_recap_gate_state( - *, - recap_feature_enabled: bool, - recap_window_open: bool, - integrations_status: IntegrationsStatus, -) -> tuple[bool, str | None, str | None]: - gate = resolve_connected_coaching_gate( - feature_enabled=recap_feature_enabled, - integrations_status=integrations_status, - locked_message="Weekly recap is not available on this plan.", - ) - if recap_window_open: - return gate.allowed, gate.attention_message, gate.gate_target - if gate.attention_message: - return False, gate.attention_message, gate.gate_target or "settings" - return False, None, None - - def resolve_coach_chat_gate_state(*, quota: dict[str, object]) -> tuple[bool, str | None, str | None]: if not bool(quota.get("is_limited")): return True, None, None @@ -445,21 +423,11 @@ async def build_thread_projection( ) pending_ids = [str(item) for item in pending_proposals_row.scalars().all()] - recap_availability = await evaluate_weekly_recap_availability(db, user_id=user_id) - usage_context = await get_local_usage_context(db, user_id=user_id) - recap_feature_enabled = has_weekly_recap_feature_access(usage_context) - integrations_status = await load_integrations_status(db, user_id=user_id) anchor = compute_recap_week_anchor_utc() quota = await get_coach_weekly_quota(db, user_id=user_id, week_anchor_utc=anchor) can_send_message, coach_gate_message, coach_gate_target = resolve_coach_chat_gate_state( quota=quota, ) - can_trigger_recap, training_provider_message, recap_gate_target = resolve_recap_gate_state( - recap_feature_enabled=recap_feature_enabled, - recap_window_open=recap_availability.allowed, - integrations_status=integrations_status, - ) - return { "thread": { "id": str(thread_snapshot.id), @@ -475,8 +443,5 @@ async def build_thread_projection( "coach_gate_target": coach_gate_target, "has_pending_proposal": len(pending_ids) > 0, "pending_proposal_ids": pending_ids, - "can_trigger_recap": can_trigger_recap, - "training_provider_message": training_provider_message, - "recap_gate_target": recap_gate_target, "next_after_seq": events[-1].seq if events else after_seq, } diff --git a/api/services/coach_patch_ops.py b/api/services/coach_patch_ops.py index e63cfaf..f062821 100644 --- a/api/services/coach_patch_ops.py +++ b/api/services/coach_patch_ops.py @@ -1,26 +1,38 @@ from __future__ import annotations import logging +from collections.abc import Sequence +from typing import Any, Literal, overload from api.services.html_sanitizer import sanitize_html from services.ai.coach.patch_apply import ( apply_delete_day_block, apply_delete_week_notes_block, apply_replace_day_blocks, + apply_replace_v3_semantic_block, apply_update_day_fields, + apply_update_v3_day_fields, + apply_update_v3_session_fields, apply_upsert_day_block, apply_upsert_week_notes_block, ) from services.ai.coach.schemas import ( + AnyPlanPatchOp, DeleteDayBlockOp, DeleteWeekNotesBlockOp, PatchOpType, PlanPatchOp, ReplaceDayBlocksOp, + ReplaceV3SemanticBlockOp, UpdateDayFieldsOp, + UpdateV3DayFieldsOp, + UpdateV3SessionFieldsOp, UpsertDayBlockOp, UpsertWeekNotesBlockOp, + V3PatchOpType, + V3PlanPatchOp, ) +from services.ai.head_coach.artifacts import ExecutionPlanArtifactV3 from services.ai.langgraph.schemas.ui_blocks import UiWeeklyPlan logger = logging.getLogger(__name__) @@ -35,7 +47,36 @@ def sanitize_ops(ops: list[PlanPatchOp]) -> list[PlanPatchOp]: return sanitized_ops -def apply_ops(plan: UiWeeklyPlan, ops: list[PlanPatchOp]) -> tuple[UiWeeklyPlan, bool]: +def prepare_ops_for_plan( + plan: UiWeeklyPlan | ExecutionPlanArtifactV3, + ops: Sequence[AnyPlanPatchOp], +) -> list[AnyPlanPatchOp]: + if isinstance(plan, ExecutionPlanArtifactV3): + if any(isinstance(op, (UpsertDayBlockOp, DeleteDayBlockOp, ReplaceDayBlocksOp, UpsertWeekNotesBlockOp, DeleteWeekNotesBlockOp, UpdateDayFieldsOp)) for op in ops): + raise ValueError("Schema-v1 patch operations cannot mutate a schema-v3 plan") + return list(ops) + + legacy_ops: list[PlanPatchOp] = [] + for op in ops: + if isinstance(op, (UpdateV3DayFieldsOp, UpdateV3SessionFieldsOp, ReplaceV3SemanticBlockOp)): + raise ValueError("Schema-v3 patch operations cannot mutate a schema-v1 plan") + legacy_ops.append(op) + return list(sanitize_ops(legacy_ops)) + + +def apply_ops( + plan: UiWeeklyPlan | ExecutionPlanArtifactV3, + ops: Sequence[AnyPlanPatchOp], +) -> tuple[UiWeeklyPlan | ExecutionPlanArtifactV3, bool]: + if isinstance(plan, ExecutionPlanArtifactV3): + return _apply_v3_ops(plan, ops) + return _apply_v1_ops(plan, ops) + + +def _apply_v1_ops(plan: UiWeeklyPlan, ops: Sequence[AnyPlanPatchOp]) -> tuple[UiWeeklyPlan, bool]: + if any(isinstance(op, (UpdateV3DayFieldsOp, UpdateV3SessionFieldsOp, ReplaceV3SemanticBlockOp)) for op in ops): + raise ValueError("Schema-v3 patch operations cannot mutate a schema-v1 plan") + updated_plan = plan changed = False for op in ops: @@ -49,15 +90,58 @@ def apply_ops(plan: UiWeeklyPlan, ops: list[PlanPatchOp]) -> tuple[UiWeeklyPlan, result = apply_upsert_week_notes_block(updated_plan, op) elif isinstance(op, UpdateDayFieldsOp): result = apply_update_day_fields(updated_plan, op) - else: + elif isinstance(op, DeleteWeekNotesBlockOp): result = apply_delete_week_notes_block(updated_plan, op) + else: + raise ValueError("Schema-v3 patch operations cannot mutate a schema-v1 plan") + if not isinstance(result.updated_plan, UiWeeklyPlan): + raise TypeError("Schema-v1 patch application returned the wrong plan type") updated_plan = result.updated_plan changed = changed or result.changed return updated_plan, changed -def parse_patch_ops(raw_ops: list[dict]) -> list[PlanPatchOp]: +def _apply_v3_ops( + plan: ExecutionPlanArtifactV3, + ops: Sequence[AnyPlanPatchOp], +) -> tuple[ExecutionPlanArtifactV3, bool]: + updated_plan = plan + changed = False + for op in ops: + if isinstance(op, UpdateV3DayFieldsOp): + result = apply_update_v3_day_fields(updated_plan, op) + elif isinstance(op, UpdateV3SessionFieldsOp): + result = apply_update_v3_session_fields(updated_plan, op) + elif isinstance(op, ReplaceV3SemanticBlockOp): + result = apply_replace_v3_semantic_block(updated_plan, op) + else: + raise ValueError("Schema-v1 patch operations cannot mutate a schema-v3 plan") + if not isinstance(result.updated_plan, ExecutionPlanArtifactV3): + raise TypeError("Schema-v3 patch application returned the wrong plan type") + updated_plan = result.updated_plan + changed = changed or result.changed + return updated_plan, changed + + +@overload +def parse_patch_ops(raw_ops: list[dict], *, schema_version: Literal[1] = 1) -> list[PlanPatchOp]: ... + + +@overload +def parse_patch_ops(raw_ops: list[dict], *, schema_version: Literal[3]) -> list[V3PlanPatchOp]: ... + + +@overload +def parse_patch_ops(raw_ops: list[dict], *, schema_version: int) -> list[AnyPlanPatchOp]: ... + + +def parse_patch_ops(raw_ops: list[dict], *, schema_version: int = 1) -> list[Any]: + if schema_version == 3: + return _parse_v3_patch_ops(raw_ops) + if schema_version != 1: + raise ValueError(f"Unsupported weekly-plan schema version: {schema_version}") + ops: list[PlanPatchOp] = [] for raw_op in raw_ops: op_type = raw_op.get("op") @@ -76,3 +160,27 @@ def parse_patch_ops(raw_ops: list[dict]) -> list[PlanPatchOp]: else: logger.warning("Unknown patch op type=%s, skipping", op_type) return ops + + +def _parse_v3_patch_ops(raw_ops: list[dict]) -> list[V3PlanPatchOp]: + ops: list[V3PlanPatchOp] = [] + for raw_op in raw_ops: + op_type = raw_op.get("op") + if op_type == V3PatchOpType.UPDATE_DAY_FIELDS.value: + ops.append(UpdateV3DayFieldsOp.model_validate(raw_op)) + elif op_type == V3PatchOpType.UPDATE_SESSION_FIELDS.value: + ops.append(UpdateV3SessionFieldsOp.model_validate(raw_op)) + elif op_type == V3PatchOpType.REPLACE_SEMANTIC_BLOCK.value: + ops.append(ReplaceV3SemanticBlockOp.model_validate(raw_op)) + else: + raise ValueError(f"Unknown schema-v3 patch operation: {op_type}") + return ops + + +def parse_weekly_plan(payload: dict) -> UiWeeklyPlan | ExecutionPlanArtifactV3: + schema_version = payload.get("schema_version", 1) + if schema_version == 1: + return UiWeeklyPlan.model_validate(payload) + if schema_version == 3: + return ExecutionPlanArtifactV3.model_validate(payload) + raise ValueError(f"Unsupported weekly-plan schema version: {schema_version}") diff --git a/api/services/coach_thread_titles.py b/api/services/coach_thread_titles.py index eb7ae4e..374b0ba 100644 --- a/api/services/coach_thread_titles.py +++ b/api/services/coach_thread_titles.py @@ -50,21 +50,14 @@ def _normalize_title(raw_title: str) -> str | None: async def generate_thread_title_from_exchange(*, user_message: str, coach_reply: str) -> str | None: try: - llm = ModelSelector.get_llm(AgentRole.COACH_TRIAGE) + llm = ModelSelector.get_llm(AgentRole.COACH_TRIAGE, enable_native_web_search=False) except RuntimeError: return None response = await llm.ainvoke( [ SystemMessage(content=_TITLE_PROMPT), - HumanMessage( - content=( - "Athlete:\n" - f"{user_message[:300]}\n\n" - "Coach:\n" - f"{coach_reply[:300]}" - ) - ), + HumanMessage(content=(f"Athlete:\n{user_message[:300]}\n\nCoach:\n{coach_reply[:300]}")), ] ) return _normalize_title(_to_text(response.content)) @@ -78,7 +71,7 @@ def derive_proactive_thread_title(alert_message: str) -> str: if not first_sentence: return "Coach Alert" if len(first_sentence) > _TITLE_MAX_CHARS: - return f"{first_sentence[:_TITLE_MAX_CHARS - 1].rstrip()}..." + return f"{first_sentence[: _TITLE_MAX_CHARS - 1].rstrip()}..." return first_sentence diff --git a/api/services/coach_turn.py b/api/services/coach_turn.py index ab25872..c155812 100644 --- a/api/services/coach_turn.py +++ b/api/services/coach_turn.py @@ -42,25 +42,27 @@ hydrate_recap_messages_by_thread, list_coach_threads, resolve_coach_chat_gate_state, - resolve_recap_gate_state, serialize_thread_event, ) from api.services.coach_memory import maybe_update_thread_memory -from api.services.coach_patch_ops import apply_ops, parse_patch_ops, sanitize_ops +from api.services.coach_patch_ops import ( + apply_ops, + parse_patch_ops, + parse_weekly_plan, + prepare_ops_for_plan, + sanitize_ops, +) from api.services.coach_quota import ( consume_coach_weekly_quota, ensure_coach_weekly_quota_available, get_coach_weekly_quota, ) from api.services.coach_thread_titles import generate_thread_title_from_exchange -from api.services.full_run_policy import evaluate_full_run_availability, evaluate_weekly_recap_availability -from api.services.integration_status import load_integrations_status -from api.services.local_usage import consume_adaptive_update, get_local_usage_context, has_weekly_recap_feature_access +from api.services.local_usage import consume_adaptive_update, get_local_usage_context from api.services.ongoing_tools import build_ongoing_tool_registry -from api.services.recap import execute_recap_turn from core.recap_schedule import compute_recap_week_anchor_utc from services.ai.coach.continuum_turn_agent import run_continuum_coach_turn -from services.ai.langgraph.schemas.ui_blocks import UiWeeklyPlan +from services.ai.head_coach.artifacts import ExecutionPlanArtifactV3 _SAFETY_KEYWORDS = ("injury", "injured", "pain", "sick", "ill", "medication") _MEDICAL_DISCLAIMER = ( @@ -163,6 +165,26 @@ def _serialize_validated_ui_day_context(*, week, day) -> dict[str, object]: } +def _serialize_v3_day_context(*, week, day) -> dict[str, object]: + return { + "source": "today_mission", + "day": { + "day_id": day.day_id, + "date": day.date.isoformat(), + "day_label": day.label, + "workout_title": day.sessions[0].title if day.sessions else None, + "focus_type": day.focus_type, + "estimated_duration_min": day.total_duration_min, + "estimated_intensity": day.intensity, + "readiness_note": None, + "is_completed": day.is_completed, + "week_id": week.week_id, + "week_label": week.title, + "week_theme": week.intent_markdown, + }, + } + + async def _resolve_turn_ui_context( db: AsyncSession, *, @@ -185,7 +207,7 @@ async def _resolve_turn_ui_context( if active_weekly is None: return None - weekly_plan = UiWeeklyPlan.model_validate(active_weekly.plan_data) + weekly_plan = parse_weekly_plan(active_weekly.plan_data) for week in weekly_plan.weeks: for day in week.days: if day.day_id != day_id: @@ -194,6 +216,8 @@ async def _resolve_turn_ui_context( return None if requested_date and requested_date != day.date.isoformat(): return None + if isinstance(weekly_plan, ExecutionPlanArtifactV3): + return _serialize_v3_day_context(week=week, day=day) return _serialize_validated_ui_day_context(week=week, day=day) return None @@ -520,15 +544,6 @@ def _serialize_turn_run(turn_run: CoachTurnRun) -> dict[str, object]: } -async def _can_request_full_run(db: AsyncSession, *, user_id: uuid.UUID) -> bool: - availability = await evaluate_full_run_availability(db, user_id=user_id) - return availability.allowed - - -async def _ensure_connected_coach_chat_available(db: AsyncSession, *, user_id: uuid.UUID): - return None - - async def _projection_payload( db: AsyncSession, *, @@ -546,9 +561,6 @@ async def _projection_payload( "coach_gate_message": projection["coach_gate_message"], "coach_gate_target": projection["coach_gate_target"], "has_pending_proposal": projection["has_pending_proposal"], - "can_trigger_recap": projection["can_trigger_recap"], - "training_provider_message": projection["training_provider_message"], - "recap_gate_target": projection["recap_gate_target"], "pending_proposal_ids": projection["pending_proposal_ids"], "next_after_seq": projection["next_after_seq"], } @@ -572,7 +584,7 @@ async def _resolve_thread_for_turn( thread_id=thread_id, ) - if action in ("text", "recap"): + if action == "text": return await get_or_create_coach_thread(db, user_id=user_id) if proposal_id is not None: @@ -641,7 +653,7 @@ async def _handle_text_turn( validated_ui_context = await _resolve_turn_ui_context(db, user_id=user_id, ui_context=ui_context) - async with build_ongoing_tool_registry(db, user_id=user_id, require_training_provider=False) as tool_registry: + async with build_ongoing_tool_registry(db, user_id=user_id) as tool_registry: context_pack = await build_turn_context( db, user_id=user_id, @@ -682,7 +694,7 @@ async def _handle_text_turn( ) full_run_reason = model_output.full_run_reason.strip() if model_output.full_run_reason else None - ops = sanitize_ops(model_output.proposal_ops) + ops = model_output.proposal_ops turn_payload: dict = { "kind": "message", "assistant_message": assistant_message, @@ -698,14 +710,15 @@ async def _handle_text_turn( if ops: active_weekly = await _get_active_weekly_plan(db, user_id=user_id) - weekly_plan = UiWeeklyPlan.model_validate(active_weekly.plan_data) - preview_plan, proposal_changed = apply_ops(weekly_plan, ops) + weekly_plan = parse_weekly_plan(active_weekly.plan_data) + prepared_ops = prepare_ops_for_plan(weekly_plan, ops) + preview_plan, proposal_changed = apply_ops(weekly_plan, prepared_ops) proposal = CoachProposal( user_id=user_id, thread_id=thread.id, weekly_plan_version=active_weekly.version, assistant_message=assistant_message, - ops={"ops": [item.model_dump(mode="json") for item in ops]}, + ops={"ops": [item.model_dump(mode="json") for item in prepared_ops]}, origin="coach_turn", status="pending", ) @@ -722,7 +735,7 @@ async def _handle_text_turn( { "proposal_id": str(proposal.id), "assistant_message": assistant_message, - "ops": [item.model_dump(mode="json") for item in ops], + "ops": [item.model_dump(mode="json") for item in prepared_ops], "base_weekly_plan": weekly_plan.model_dump(mode="json"), "preview_weekly_plan": preview_plan.model_dump(mode="json"), "origin": proposal.origin, @@ -741,7 +754,7 @@ async def _handle_text_turn( "changed": proposal_changed, "base_weekly_plan": weekly_plan.model_dump(mode="json"), "preview_weekly_plan": preview_plan.model_dump(mode="json"), - "ops": [item.model_dump(mode="json") for item in ops], + "ops": [item.model_dump(mode="json") for item in prepared_ops], "requests_full_run": model_output.requests_full_run, "full_run_reason": full_run_reason, "safety_flags": model_output.safety_flags, @@ -754,7 +767,7 @@ async def _handle_text_turn( ) created_events.extend(coach_events) - if model_output.requests_full_run and await _can_request_full_run(db, user_id=user_id): + if model_output.requests_full_run: if full_run_reason: full_run_events = await append_coach_events( db, @@ -823,10 +836,12 @@ async def _handle_accept_turn( if active_weekly.version != proposal.weekly_plan_version: raise HTTPException(status_code=409, detail="Weekly plan changed since proposal") - weekly_plan = UiWeeklyPlan.model_validate(active_weekly.plan_data) + weekly_plan = parse_weekly_plan(active_weekly.plan_data) raw_ops = proposal.ops.get("ops", []) if isinstance(proposal.ops, dict) else [] - parsed_ops = sanitize_ops(parse_patch_ops(raw_ops)) - updated_plan, changed = apply_ops(weekly_plan, parsed_ops) + if isinstance(weekly_plan, ExecutionPlanArtifactV3): + updated_plan, changed = apply_ops(weekly_plan, parse_patch_ops(raw_ops, schema_version=3)) + else: + updated_plan, changed = apply_ops(weekly_plan, sanitize_ops(parse_patch_ops(raw_ops))) updated_payload = updated_plan.model_dump(mode="json") adaptive_usage = None if changed: @@ -925,7 +940,6 @@ async def _run_turn_action( status_emitter: StatusEmitter | None = None, ) -> tuple[dict, list]: if action == "text": - await _ensure_connected_coach_chat_available(db, user_id=user_id) return await _handle_text_turn( db, user_id=user_id, @@ -954,14 +968,7 @@ async def _run_turn_action( proposal_id=proposal_id, reason=reason or "", ) - if action == "recap": - return await execute_recap_turn( - db, - user_id=user_id, - thread=thread, - status_emitter=status_emitter, - ) - raise HTTPException(status_code=400, detail="action must be text, proposal_accept, proposal_reject, or recap") + raise HTTPException(status_code=400, detail="action must be text, proposal_accept, or proposal_reject") async def post_coach_turn( @@ -1020,7 +1027,7 @@ async def post_coach_turn( reason=reason, quota_source_id=f"{user_id}:{key}", ui_context=ui_context, - status_emitter=status_emitter if action in ("text", "recap") else None, + status_emitter=status_emitter if action == "text" else None, ) if should_set_title_from_first_exchange: await _maybe_set_thread_title_from_first_exchange( @@ -1038,9 +1045,6 @@ async def post_coach_turn( "coach_gate_message": projection["coach_gate_message"], "coach_gate_target": projection["coach_gate_target"], "has_pending_proposal": projection["has_pending_proposal"], - "can_trigger_recap": projection["can_trigger_recap"], - "training_provider_message": projection["training_provider_message"], - "recap_gate_target": projection["recap_gate_target"], "pending_proposal_ids": projection["pending_proposal_ids"], "next_after_seq": projection["next_after_seq"], } @@ -1081,17 +1085,9 @@ async def get_coach_thread_v2( if thread is None: anchor = compute_recap_week_anchor_utc() quota = await get_coach_weekly_quota(db, user_id=user_id, week_anchor_utc=anchor) - recap_availability = await evaluate_weekly_recap_availability(db, user_id=user_id) - usage_context = await get_local_usage_context(db, user_id=user_id) - integrations_status = await load_integrations_status(db, user_id=user_id) can_send_message, coach_gate_message, coach_gate_target = resolve_coach_chat_gate_state( quota=quota, ) - can_trigger_recap, training_provider_message, recap_gate_target = resolve_recap_gate_state( - recap_feature_enabled=has_weekly_recap_feature_access(usage_context), - recap_window_open=recap_availability.allowed, - integrations_status=integrations_status, - ) return { "thread": None, "messages": [], @@ -1100,9 +1096,6 @@ async def get_coach_thread_v2( "coach_gate_message": coach_gate_message, "coach_gate_target": coach_gate_target, "has_pending_proposal": False, - "can_trigger_recap": can_trigger_recap, - "training_provider_message": training_provider_message, - "recap_gate_target": recap_gate_target, "pending_proposal_ids": [], "next_after_seq": after_seq, "week_anchor_utc": anchor.astimezone(UTC).isoformat(), @@ -1121,9 +1114,6 @@ async def get_coach_thread_v2( "coach_gate_message": projection["coach_gate_message"], "coach_gate_target": projection["coach_gate_target"], "has_pending_proposal": projection["has_pending_proposal"], - "can_trigger_recap": projection["can_trigger_recap"], - "training_provider_message": projection["training_provider_message"], - "recap_gate_target": projection["recap_gate_target"], "pending_proposal_ids": projection["pending_proposal_ids"], "next_after_seq": projection["next_after_seq"], "week_anchor_utc": compute_recap_week_anchor_utc().astimezone(UTC).isoformat(), diff --git a/api/services/connected_coaching.py b/api/services/connected_coaching.py deleted file mode 100644 index 109c124..0000000 --- a/api/services/connected_coaching.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -from fastapi import HTTPException - -from api.services.evidence_profile import build_evidence_profile -from api.services.integration_status import ( - IntegrationsStatus, - training_provider_notice_message, - training_provider_requirement_message, -) - -GateTarget = Literal["settings"] - - -@dataclass(frozen=True) -class ConnectedCoachingGateState: - allowed: bool - attention_message: str | None - gate_target: GateTarget | None - evidence_profile: dict[str, object] - - -def _provider_status_payload(status: IntegrationsStatus) -> dict[str, dict[str, object]]: - return { - "strava": { - "available": status.strava.operational, - "state": status.strava.state, - "linked": status.strava.linked, - }, - "whoop": { - "available": status.whoop.operational, - "state": status.whoop.state, - "linked": status.whoop.linked, - }, - } - - -def resolve_connected_coaching_gate( - *, - feature_enabled: bool, - integrations_status: IntegrationsStatus, - locked_message: str, -) -> ConnectedCoachingGateState: - evidence_profile = build_evidence_profile( - provider_status=_provider_status_payload(integrations_status), - ) - connected_mode = str(evidence_profile.get("connected_mode") or "none").strip().lower() - has_relevant_evidence = connected_mode != "none" - provider_notice = training_provider_notice_message(integrations_status) - - if not feature_enabled: - return ConnectedCoachingGateState( - allowed=False, - attention_message=locked_message, - gate_target="settings", - evidence_profile=evidence_profile, - ) - - if not has_relevant_evidence: - return ConnectedCoachingGateState( - allowed=False, - attention_message=provider_notice or training_provider_requirement_message(integrations_status), - gate_target="settings", - evidence_profile=evidence_profile, - ) - - if provider_notice: - return ConnectedCoachingGateState( - allowed=True, - attention_message=provider_notice, - gate_target="settings", - evidence_profile=evidence_profile, - ) - - return ConnectedCoachingGateState( - allowed=True, - attention_message=None, - gate_target=None, - evidence_profile=evidence_profile, - ) - - -def assert_connected_coaching_available( - *, - feature_enabled: bool, - integrations_status: IntegrationsStatus, - locked_message: str, -) -> ConnectedCoachingGateState: - gate_state = resolve_connected_coaching_gate( - feature_enabled=feature_enabled, - integrations_status=integrations_status, - locked_message=locked_message, - ) - if gate_state.allowed: - return gate_state - - raise HTTPException(status_code=400, detail=gate_state.attention_message or locked_message) diff --git a/api/services/daily_sync_sources.py b/api/services/daily_sync_sources.py deleted file mode 100644 index 5a41c9e..0000000 --- a/api/services/daily_sync_sources.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping - -_CANONICAL_PROVIDER_ORDER = ("strava", "whoop") - - -def extract_daily_sync_sources(prefetched_recovery_readiness: object) -> list[str]: - if not isinstance(prefetched_recovery_readiness, Mapping): - return [] - - raw_sources = prefetched_recovery_readiness.get("sources") - if not isinstance(raw_sources, Mapping): - return [] - - sources_used: list[str] = [] - seen_provider_names: set[str] = set() - for provider_name in _CANONICAL_PROVIDER_ORDER: - provider_payload = raw_sources.get(provider_name) - if isinstance(provider_payload, Mapping): - sources_used.append(provider_name) - seen_provider_names.add(provider_name) - - for provider_name, provider_payload in raw_sources.items(): - if not isinstance(provider_name, str) or provider_name in seen_provider_names: - continue - if isinstance(provider_payload, Mapping): - sources_used.append(provider_name) - - return sources_used diff --git a/api/services/dashboard_state.py b/api/services/dashboard_state.py index 07f5cb6..612facb 100644 --- a/api/services/dashboard_state.py +++ b/api/services/dashboard_state.py @@ -6,10 +6,9 @@ from datetime import UTC, date, datetime from typing import Any -from sqlalchemy import desc, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from api.config import get_settings from api.models.athlete_profile import AthleteProfile from api.models.coach_proposal import CoachProposal from api.models.coach_thread import CoachThread @@ -18,19 +17,11 @@ from api.models.weekly_recap_run import WeeklyRecapRun from api.services.active_plans import get_active_analysis, get_active_season_plan, get_active_weekly_plan from api.services.athlete_time import get_athlete_time_context -from api.services.connected_coaching import resolve_connected_coaching_gate -from api.services.daily_sync_sources import extract_daily_sync_sources -from api.services.daily_update_runs import fail_stale_pending_daily_update_run -from api.services.full_run_policy import evaluate_weekly_recap_availability from api.services.html_sanitizer import sanitize_html -from api.services.integration_status import load_integrations_status, training_provider_notice_message from api.services.local_readiness import format_llm_provider_key_names, has_llm_provider_key -from api.services.local_usage import get_local_usage_context, has_weekly_recap_feature_access from api.services.recap import ( extract_recap_action_preview, extract_recap_summary_preview, - get_recap_pending_action, - get_recap_thread_id, ) from services.ai.langgraph.schemas.ui_blocks import UiDayPlan, UiDisclosureNode, UiHtmlBlock, UiKpi, UiWeeklyPlan @@ -100,19 +91,19 @@ def _build_first_run_state( if has_active_plan: next_step = "generated" title = "Training plan ready" - body = "Your current plan is available locally. Connected sources can improve daily sync and recaps, but they are not required to view or discuss the plan." + body = "Your current plan is available locally and ready for plan-aware coach conversations." primary_action = {"label": "Open training plan", "href": "/app/plan"} secondary_actions = [{"label": "Ask coach", "href": "/app/coach"}] elif not llm_ready: next_step = "llm_key" title = "Add one LLM key" - body = "Manual Mode can run without Strava or WHOOP, but local plan generation still needs one supported LLM provider key." + body = "Provider-free planning needs no external training-data account, but local generation still needs one supported LLM provider key." primary_action = None secondary_actions = [] elif not profile_ready: next_step = "profile" title = "Complete your athlete profile" - body = "Manual Mode uses your declared physiology, sports, availability, and constraints as the baseline planning evidence." + body = "Your declared physiology, sports, availability, and constraints are the baseline planning evidence." primary_action = {"label": "Complete profile", "href": "/app/profile"} secondary_actions = [] elif not goal_ready: @@ -124,7 +115,7 @@ def _build_first_run_state( else: next_step = "generate" title = "Generate your first local plan" - body = "Ready for Draft Mode: the planner will use your saved profile, goal/race context, and any notes you add on the generation screen." + body = "No wearable required: the planner will use your saved profile, goal/race context, availability, constraints, and generation notes." primary_action = {"label": "Generate plan", "href": "/app/new"} secondary_actions = [ {"label": "Review profile", "href": "/app/profile"}, @@ -150,15 +141,6 @@ def _build_first_run_state( } -async def _load_dashboard_integrations_context(db: AsyncSession, *, user_id): - integrations_status = await load_integrations_status( - db, - user_id=user_id, - settings=get_settings(), - ) - return integrations_status, training_provider_notice_message(integrations_status) - - def _resolve_daily_sync_status( *, daily_run: DailyUpdateRun | None, @@ -615,23 +597,6 @@ async def build_dashboard_state(db: AsyncSession, *, user_id) -> dict[str, Any]: weekly_payload = await get_active_weekly_plan(db, user_id=user_id) profile_row = await db.execute(select(AthleteProfile.profile).where(AthleteProfile.user_id == user_id)) competitions_row = await db.execute(select(Competition.id).where(Competition.user_id == user_id)) - daily_run_row = await db.execute( - select(DailyUpdateRun).where( - DailyUpdateRun.user_id == user_id, - DailyUpdateRun.target_date == today_local_date, - ) - ) - completed_daily_runs_row = await db.execute( - select(DailyUpdateRun) - .where( - DailyUpdateRun.user_id == user_id, - DailyUpdateRun.status.in_(("done", "completed")), - ) - .order_by(desc(DailyUpdateRun.target_date), desc(DailyUpdateRun.updated_at)) - ) - recap_availability = await evaluate_weekly_recap_availability(db, user_id=user_id) - usage_context = await get_local_usage_context(db, user_id=user_id) - recap_feature_enabled = has_weekly_recap_feature_access(usage_context) pending_proposal_row = await db.execute( select(CoachProposal) .join(CoachThread, CoachProposal.thread_id == CoachThread.id) @@ -648,49 +613,26 @@ async def build_dashboard_state(db: AsyncSession, *, user_id) -> dict[str, Any]: profile_payload = profile_row.scalar_one_or_none() has_competitions = bool(competitions_row.scalars().first()) warnings = _dashboard_warnings(profile=profile_payload, has_competitions=has_competitions) - integrations_status, provider_notice = await _load_dashboard_integrations_context( - db, - user_id=user_id, + weekly_plan_payload = weekly_payload["weekly_plan"] if weekly_payload else None + legacy_weekly_plan = ( + UiWeeklyPlan.model_validate(weekly_plan_payload) + if isinstance(weekly_plan_payload, dict) and weekly_plan_payload.get("schema_version") == 1 + else None ) - if provider_notice and provider_notice not in warnings: - warnings.append(provider_notice) - - weekly_plan_model = UiWeeklyPlan.model_validate(weekly_payload["weekly_plan"]) if weekly_payload else None - - daily_run = daily_run_row.scalar_one_or_none() - completed_daily_runs = completed_daily_runs_row.scalars().all() - await fail_stale_pending_daily_update_run(db, run=daily_run, now=athlete_time.now_local) - daily_payload = daily_run.update_payload if daily_run and isinstance(daily_run.update_payload, dict) else {} status_surface = _build_status_surface( analysis_payload=analysis_payload, - today_daily_run=daily_run, - completed_daily_runs=completed_daily_runs, - daily_payload=daily_payload, + today_daily_run=None, + completed_daily_runs=[], + daily_payload={}, today_local_iso=today_local_iso, ) analysis_coach_surface, _ = _build_analysis_coach_surface(analysis_payload) - today_focus_blocks = _sanitize_focus_blocks(daily_payload.get("today_focus_blocks")) - daily_coach_surface, daily_surface_updated_at = _build_daily_coach_surface( - daily_run=daily_run, - today_focus_blocks=today_focus_blocks, - ) day_override = compose_day_override( - weekly_plan=weekly_plan_model, + weekly_plan=legacy_weekly_plan, target_date_iso=today_local_iso, - today_focus_blocks=today_focus_blocks, - ) - - daily_thread_id = await _get_daily_thread_id(db, run=daily_run) - daily_status, daily_visible, daily_error = _resolve_daily_sync_status( - daily_run=daily_run, - weekly_plan_available=weekly_plan_model is not None, + today_focus_blocks=[], ) - daily_gate = resolve_connected_coaching_gate( - feature_enabled=True, - integrations_status=integrations_status, - locked_message="Daily coaching requires a connected training or recovery source.", - ) - has_connected_source = str(daily_gate.evidence_profile.get("connected_mode") or "none") != "none" + has_connected_source = False has_active_plan = season_payload is not None or weekly_payload is not None first_run_state = _build_first_run_state( profile=profile_payload, @@ -698,27 +640,12 @@ async def build_dashboard_state(db: AsyncSession, *, user_id) -> dict[str, Any]: has_active_plan=has_active_plan, has_connected_source=has_connected_source, ) - daily_attention_message = _resolve_daily_sync_attention(provider_notice=daily_gate.attention_message) - daily_can_run = weekly_plan_model is not None and daily_gate.allowed - - verdict_preview = _extract_html_text(today_focus_blocks[0].content_html) if today_focus_blocks else None - prefetched_recovery_readiness = ( - daily_run.context_snapshot.get("prefetched_recovery_readiness") - if daily_run is not None and isinstance(daily_run.context_snapshot, dict) - else None - ) - sources_used = extract_daily_sync_sources(prefetched_recovery_readiness) + daily_attention_message = "Daily Sync is not included in the provider-free v2.2.0 release." + daily_can_run = False - recap_gate = resolve_connected_coaching_gate( - feature_enabled=True, - integrations_status=integrations_status, - locked_message="Weekly recap requires a connected training or recovery source.", - ) - recap_attention_message = recap_gate.attention_message - recap_gate_target = recap_gate.gate_target recap_state = { "visible": False, - "allowed": recap_availability.allowed and recap_feature_enabled, + "allowed": False, "status": "hidden", "thread_id": None, "proposal_id": None, @@ -726,57 +653,16 @@ async def build_dashboard_state(db: AsyncSession, *, user_id) -> dict[str, Any]: "summary_preview": None, "pending_action": "none", "can_run": False, - "attention_message": recap_attention_message, - "gate_target": recap_gate_target, + "attention_message": "Weekly Recap is not included in the provider-free v2.2.0 release.", + "gate_target": None, } - recap_coach_surface: dict[str, Any] | None = None - recap_surface_updated_at: datetime | None = None - - if recap_availability.allowed and recap_feature_enabled: - recap_state.update( - { - "visible": True, - "allowed": True, - "status": "ready", - "can_run": recap_gate.allowed, - "attention_message": recap_attention_message, - "gate_target": recap_gate_target, - } - ) - elif recap_availability.existing_run_id is not None: - recap_run_row = await db.execute( - select(WeeklyRecapRun).where( - WeeklyRecapRun.id == recap_availability.existing_run_id, - WeeklyRecapRun.user_id == user_id, - ) - ) - recap_run = recap_run_row.scalar_one_or_none() - if recap_run is not None: - recap_thread_id = await get_recap_thread_id(db, run=recap_run) - pending_action = await get_recap_pending_action(db, run=recap_run) - recap_coach_surface, recap_surface_updated_at = _build_weekly_recap_coach_surface(recap_run) - recap_state.update( - { - "visible": True, - "allowed": False, - "status": "completed_this_window", - "thread_id": recap_thread_id, - "proposal_id": str(recap_run.proposal_id) if recap_run.proposal_id else None, - "follow_up_question": recap_run.follow_up_question, - "summary_preview": extract_recap_summary_preview(recap_run.recap_payload), - "pending_action": pending_action, - "can_run": False, - "attention_message": recap_attention_message, - "gate_target": recap_gate_target, - } - ) pending_proposal = pending_proposal_row.scalar_one_or_none() coach_surface = _select_coach_surface( - daily_surface=daily_coach_surface, - daily_updated_at=daily_surface_updated_at, - recap_surface=recap_coach_surface, - recap_updated_at=recap_surface_updated_at, + daily_surface=None, + daily_updated_at=None, + recap_surface=None, + recap_updated_at=None, analysis_surface=analysis_coach_surface, ) @@ -798,17 +684,17 @@ async def build_dashboard_state(db: AsyncSession, *, user_id) -> dict[str, Any]: "day_override": day_override.model_dump(mode="json") if day_override is not None else None, }, "daily_sync": { - "visible": daily_visible, - "status": daily_status, - "run_id": str(daily_run.id) if daily_run is not None else None, - "verdict_preview": verdict_preview, - "sources_used": sources_used, - "proposal_id": str(daily_run.proposal_id) if daily_run and daily_run.proposal_id else None, - "thread_id": daily_thread_id, - "error_message": daily_error, + "visible": False, + "status": "idle", + "run_id": None, + "verdict_preview": None, + "sources_used": [], + "proposal_id": None, + "thread_id": None, + "error_message": None, "can_run": daily_can_run, "attention_message": daily_attention_message, - "gate_target": daily_gate.gate_target, + "gate_target": None, }, "weekly_recap": recap_state, "pending_proposal_banner": ( diff --git a/api/services/evidence_profile.py b/api/services/evidence_profile.py deleted file mode 100644 index 80d5203..0000000 --- a/api/services/evidence_profile.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable, Mapping - -_CANONICAL_PROVIDER_ORDER = ("strava", "whoop") -_PROVIDER_CAPABILITIES = { - "strava": ("activity_history", "activity_detail", "training_load", "readiness_proxy"), - "whoop": ("recovery_biometrics", "readiness", "sleep", "activity_context"), -} - - -def _availability_confidence(availability: str) -> str: - if availability == "strong": - return "high" - if availability in {"partial", "proxy_only"}: - return "medium" - return "low" - - -def _provider_names( - *, - available_sources: set[str], - provider_status: Mapping[str, Mapping[str, object]] | None, -) -> list[str]: - names: list[str] = list(_CANONICAL_PROVIDER_ORDER) - for source_name in sorted(available_sources): - if source_name not in names: - names.append(source_name) - if provider_status is not None: - for source_name in provider_status.keys(): - if source_name not in names: - names.append(source_name) - return names - - -def build_evidence_sources( - *, - available_sources: Iterable[str] = (), - provider_status: Mapping[str, Mapping[str, object]] | None = None, -) -> dict[str, dict[str, object]]: - available_source_names = { - source_name.strip().lower() - for source_name in available_sources - if isinstance(source_name, str) and source_name.strip() - } - - sources: dict[str, dict[str, object]] = {} - for source_name in _provider_names( - available_sources=available_source_names, - provider_status=provider_status, - ): - status_payload = provider_status.get(source_name) if provider_status is not None else None - operational = source_name in available_source_names - if isinstance(status_payload, Mapping) and "available" in status_payload: - operational = bool(status_payload.get("available")) - sources[source_name] = { - "operational": operational, - "capabilities": list(_PROVIDER_CAPABILITIES.get(source_name, ())), - } - return sources - - -def build_evidence_profile( - *, - available_sources: Iterable[str] = (), - provider_status: Mapping[str, Mapping[str, object]] | None = None, -) -> dict[str, object]: - sources = build_evidence_sources( - available_sources=available_sources, - provider_status=provider_status, - ) - strava_operational = bool(sources.get("strava", {}).get("operational")) - whoop_operational = bool(sources.get("whoop", {}).get("operational")) - - connected_mode = "none" - if strava_operational and whoop_operational: - connected_mode = "both" - elif strava_operational: - connected_mode = "strava_only" - elif whoop_operational: - connected_mode = "whoop_only" - - activity_history_availability = "strong" if strava_operational else ("partial" if whoop_operational else "none") - training_load_availability = "strong" if strava_operational else ("partial" if whoop_operational else "none") - recovery_biometrics_availability = "strong" if whoop_operational else "none" - readiness_guidance_availability = "strong" if whoop_operational else ("proxy_only" if strava_operational else "none") - - dimensions = { - "activity_history": { - "availability": activity_history_availability, - "confidence": _availability_confidence(activity_history_availability), - }, - "training_load": { - "availability": training_load_availability, - "confidence": _availability_confidence(training_load_availability), - }, - "recovery_biometrics": { - "availability": recovery_biometrics_availability, - "confidence": _availability_confidence(recovery_biometrics_availability), - }, - "readiness_guidance": { - "availability": readiness_guidance_availability, - "confidence": _availability_confidence(readiness_guidance_availability), - }, - "subjective_feedback": { - "availability": "none", - "confidence": "low", - }, - } - - claims_policy = { - "can_make_activity_completeness_claims": strava_operational, - "can_make_training_load_claims": strava_operational or whoop_operational, - "can_make_readiness_claims": whoop_operational, - "can_use_declared_profile_claims": True, - "can_use_declared_goal_claims": True, - "has_connected_training_source": strava_operational or whoop_operational, - "should_acknowledge_missing_recovery_evidence": strava_operational and not whoop_operational, - "should_acknowledge_missing_activity_history": whoop_operational and not strava_operational, - "should_acknowledge_no_connected_sources": not strava_operational and not whoop_operational, - "should_frame_guidance_as_proxy_based": strava_operational and not whoop_operational, - "should_frame_activity_load_as_unavailable": not strava_operational and not whoop_operational, - "should_frame_recovery_readiness_as_unavailable": not whoop_operational, - } - - return { - "connected_mode": connected_mode, - "sources": sources, - "dimensions": dimensions, - "claims_policy": claims_policy, - } diff --git a/api/services/full_run_policy.py b/api/services/full_run_policy.py index 88049bf..ea3f62c 100644 --- a/api/services/full_run_policy.py +++ b/api/services/full_run_policy.py @@ -18,20 +18,12 @@ recap_first_anchor_utc, recap_window_utc, ) -from api.services.local_usage.usage import FEATURE_FULL_RUN +from api.services.local_usage_features import FEATURE_FULL_RUN -FULL_RUN_MIN_INTERVAL = timedelta(weeks=4) WEEKLY_RECAP_INTERVAL = timedelta(days=7) _RECAP_ACTIVE_STATUSES = ("pending", "completed") -@dataclass(frozen=True) -class FullRunAvailability: - allowed: bool - last_run_at: datetime | None - next_allowed_at: datetime | None - - @dataclass(frozen=True) class WeeklyRecapAvailability: allowed: bool @@ -70,26 +62,6 @@ async def get_latest_full_run_created_at(db: AsyncSession, *, user_id: uuid.UUID return None -async def evaluate_full_run_availability( - db: AsyncSession, - *, - user_id: uuid.UUID, - now: datetime | None = None, - min_interval: timedelta | None = None, -) -> FullRunAvailability: - now_utc = as_utc(now or datetime.now(UTC)) - interval = min_interval or FULL_RUN_MIN_INTERVAL - last_run_at = await get_latest_full_run_created_at(db, user_id=user_id) - if last_run_at is None: - return FullRunAvailability(allowed=True, last_run_at=None, next_allowed_at=None) - next_allowed_at = last_run_at + interval - return FullRunAvailability( - allowed=now_utc >= next_allowed_at, - last_run_at=last_run_at, - next_allowed_at=next_allowed_at, - ) - - async def evaluate_weekly_recap_availability( db: AsyncSession, *, diff --git a/api/services/head_coach_checkpoint_retention.py b/api/services/head_coach_checkpoint_retention.py new file mode 100644 index 0000000..3caf8ef --- /dev/null +++ b/api/services/head_coach_checkpoint_retention.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.models.coach_turn_run import CoachTurnRun +from api.models.job import AnalysisJob, JobStatus +from services.ai.head_coach.checkpointing import ( + CheckpointScope, + build_checkpoint_identity, + delete_checkpoint_thread, +) + + +class CheckpointCleanupSummary(BaseModel): + model_config = ConfigDict(frozen=True) + + analysis_threads_deleted: int = 0 + coach_executions_deleted: int = 0 + checkpoint_rows_deleted: int = 0 + + +async def cleanup_expired_head_coach_checkpoints( + db: AsyncSession, + *, + cutoff: datetime, +) -> CheckpointCleanupSummary: + analysis_result = await db.execute( + select(AnalysisJob.user_id, AnalysisJob.id).where( + AnalysisJob.status.in_( + [ + JobStatus.COMPLETED.value, + JobStatus.FAILED.value, + JobStatus.CANCELLED.value, + ] + ), + AnalysisJob.completed_at.is_not(None), + AnalysisJob.completed_at < cutoff, + ) + ) + coach_result = await db.execute( + select(CoachTurnRun.user_id, CoachTurnRun.thread_id, CoachTurnRun.id).where( + CoachTurnRun.status.in_(["completed", "failed", "cancelled"]), + CoachTurnRun.created_at < cutoff, + ) + ) + + analysis_rows = analysis_result.all() + coach_rows = coach_result.all() + deleted_rows = 0 + for owner_id, analysis_id in analysis_rows: + identity = build_checkpoint_identity( + owner_id=owner_id, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=analysis_id, + ) + deleted_rows += await delete_checkpoint_thread(db, thread_id=identity.thread_id) + + for owner_id, thread_id, run_id in coach_rows: + identity = build_checkpoint_identity( + owner_id=owner_id, + scope=CheckpointScope.COACH_TURN, + resource_id=thread_id, + execution_id=run_id, + ) + deleted_rows += await delete_checkpoint_thread(db, thread_id=identity.thread_id) + + return CheckpointCleanupSummary( + analysis_threads_deleted=len(analysis_rows), + coach_executions_deleted=len(coach_rows), + checkpoint_rows_deleted=deleted_rows, + ) diff --git a/api/services/integration_connections.py b/api/services/integration_connections.py deleted file mode 100644 index b3f30c3..0000000 --- a/api/services/integration_connections.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import logging -import uuid -from datetime import UTC, datetime - -from sqlalchemy import select -from sqlalchemy.exc import ProgrammingError - -from api.models.integration_connection import IntegrationConnection - -_SUPPORTED_PROVIDERS = {"strava", "whoop"} -_MISSING_HISTORY_TABLE_SQLSTATE = "42P01" - -logger = logging.getLogger(__name__) - -_missing_history_table_warning_state = {"emitted": False} - - -def _normalize_provider(provider: str) -> str: - normalized = str(provider or "").strip().lower() - if normalized not in _SUPPORTED_PROVIDERS: - raise ValueError(f"Unsupported provider: {provider}") - return normalized - - -def _is_missing_history_table_error(error: ProgrammingError) -> bool: - original_error = getattr(error, "orig", None) - sqlstate = getattr(original_error, "sqlstate", None) or getattr(original_error, "pgcode", None) - if sqlstate == _MISSING_HISTORY_TABLE_SQLSTATE: - return True - return 'relation "integration_connections" does not exist' in str(original_error or error).lower() - - -def _warn_missing_history_table_once(): - if _missing_history_table_warning_state["emitted"]: - return - logger.warning( - "Integration connection history table is unavailable; falling back to empty history until migrations are applied." - ) - _missing_history_table_warning_state["emitted"] = True - - -async def get_connection_history_map(db, *, user_id: uuid.UUID) -> dict[str, IntegrationConnection]: - try: - rows = await db.execute(select(IntegrationConnection).where(IntegrationConnection.user_id == user_id)) - except ProgrammingError as error: - if not _is_missing_history_table_error(error): - raise - _warn_missing_history_table_once() - return {} - return {row.provider: row for row in rows.scalars().all()} - - -async def mark_integration_connected( - db, - *, - user_id: uuid.UUID, - provider: str, - connected_at: datetime | None = None, -): - normalized_provider = _normalize_provider(provider) - resolved_connected_at = connected_at.astimezone(UTC) if connected_at is not None else datetime.now(UTC) - try: - row = await db.execute( - select(IntegrationConnection).where( - IntegrationConnection.user_id == user_id, - IntegrationConnection.provider == normalized_provider, - ) - ) - except ProgrammingError as error: - if not _is_missing_history_table_error(error): - raise - _warn_missing_history_table_once() - return - history = row.scalar_one_or_none() - if history is None: - db.add( - IntegrationConnection( - user_id=user_id, - provider=normalized_provider, - first_connected_at=resolved_connected_at, - last_connected_at=resolved_connected_at, - last_disconnected_at=None, - last_disconnect_reason=None, - ) - ) - await db.flush() - return - - history.last_connected_at = resolved_connected_at - history.last_disconnected_at = None - history.last_disconnect_reason = None - db.add(history) - await db.flush() - - -async def mark_integration_disconnected( - db, - *, - user_id: uuid.UUID, - provider: str, - reason: str, - disconnected_at: datetime | None = None, -): - normalized_provider = _normalize_provider(provider) - resolved_disconnected_at = ( - disconnected_at.astimezone(UTC) if disconnected_at is not None else datetime.now(UTC) - ) - try: - row = await db.execute( - select(IntegrationConnection).where( - IntegrationConnection.user_id == user_id, - IntegrationConnection.provider == normalized_provider, - ) - ) - except ProgrammingError as error: - if not _is_missing_history_table_error(error): - raise - _warn_missing_history_table_once() - return - history = row.scalar_one_or_none() - if history is None: - db.add( - IntegrationConnection( - user_id=user_id, - provider=normalized_provider, - first_connected_at=resolved_disconnected_at, - last_connected_at=resolved_disconnected_at, - last_disconnected_at=resolved_disconnected_at, - last_disconnect_reason=reason, - ) - ) - await db.flush() - return - - history.last_disconnected_at = resolved_disconnected_at - history.last_disconnect_reason = str(reason or "").strip() or None - db.add(history) - await db.flush() diff --git a/api/services/integration_status.py b/api/services/integration_status.py deleted file mode 100644 index 22e1841..0000000 --- a/api/services/integration_status.py +++ /dev/null @@ -1,646 +0,0 @@ -from __future__ import annotations - -import uuid -from datetime import UTC, datetime -from typing import Any, Literal, TypedDict - -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from api.config import Settings, get_settings -from api.models.credentials import StravaCredentials, WhoopCredentials -from api.models.integration_connection import IntegrationConnection -from api.models.oauth_session import OAuthSession -from api.services.integration_connections import get_connection_history_map - -IntegrationState = Literal["connected", "attention_needed", "disconnected"] -ProviderConnectionState = Literal[ - "disabled", - "unconfigured", - "disconnected", - "started", - "callback_failed", - "token_missing", - "token_expired", - "partial_permissions", - "stale", - "syncing", - "connected_no_data", - "connected_usable", -] - -_REQUIRED_STRAVA_SCOPES = frozenset({"activity:read_all"}) -_STRAVA_TOKEN_MISSING = "Strava is linked, but the access token is missing. Reconnect Strava." -_STRAVA_SCOPE_MISSING = ( - "Strava is linked, but the accepted scopes do not allow complete activity history. " - "Reconnect Strava and grant full activity access." -) -_STRAVA_REFRESH_MISSING = "Strava connection expired and cannot refresh. Reconnect Strava." -_STRAVA_REFRESH_UNCONFIGURED = "Strava needs token refresh, but OAuth refresh is not configured in this environment." -_WHOOP_TOKEN_MISSING = "WHOOP is linked, but the access token is missing. Reconnect WHOOP." -_WHOOP_REFRESH_MISSING = "WHOOP connection expired and cannot refresh. Reconnect WHOOP." -_WHOOP_REFRESH_UNCONFIGURED = "WHOOP needs token refresh, but OAuth refresh is not configured in this environment." -_STRAVA_DISABLED = "Strava connector is disabled. Set STRAVA_OAUTH_ENABLED=true to use it." -_WHOOP_DISABLED = "WHOOP connector is disabled. Set WHOOP_OAUTH_ENABLED=true to use it." -_STRAVA_UNCONFIGURED = "Strava connector is enabled, but local OAuth credentials or redirect URI are missing." -_WHOOP_UNCONFIGURED = "WHOOP connector is enabled, but local OAuth credentials or redirect URI are missing." -_STRAVA_STARTED = "Strava connection was started. Complete the provider approval flow or restart it." -_WHOOP_STARTED = "WHOOP connection was started. Complete the provider approval flow or restart it." -_STRAVA_CALLBACK_FAILED = "The last Strava callback did not store credentials. Restart the connection flow." -_WHOOP_CALLBACK_FAILED = "The last WHOOP callback did not store credentials. Restart the connection flow." - - -class StravaIntegrationStatus(BaseModel): - linked: bool - ever_connected: bool - connected: bool - operational: bool - state: IntegrationState - connection_state: ProviderConnectionState - configured: bool - oauth_enabled: bool - attention_message: str | None = None - athlete_id: int | None = None - expires_at: str | None = None - scope: str | None = None - first_connected_at: str | None = None - last_connected_at: str | None = None - last_disconnected_at: str | None = None - last_disconnect_reason: str | None = None - - -class WhoopIntegrationStatus(BaseModel): - linked: bool - ever_connected: bool - connected: bool - operational: bool - state: IntegrationState - connection_state: ProviderConnectionState - configured: bool - oauth_enabled: bool - attention_message: str | None = None - whoop_user_id: int | None = None - expires_at: str | None = None - scope: str | None = None - first_connected_at: str | None = None - last_connected_at: str | None = None - last_disconnected_at: str | None = None - last_disconnect_reason: str | None = None - - -class IntegrationsStatus(BaseModel): - strava: StravaIntegrationStatus - whoop: WhoopIntegrationStatus - - -class _HistoryPayload(TypedDict): - ever_connected: bool - first_connected_at: str | None - last_connected_at: str | None - last_disconnected_at: str | None - last_disconnect_reason: str | None - - -def _iso_timestamp(value: datetime | None) -> str | None: - if value is None: - return None - return value.astimezone(UTC).isoformat() - - -def _history_payload(history: IntegrationConnection | None) -> _HistoryPayload: - return { - "ever_connected": history is not None, - "first_connected_at": _iso_timestamp(history.first_connected_at) if history is not None else None, - "last_connected_at": _iso_timestamp(history.last_connected_at) if history is not None else None, - "last_disconnected_at": _iso_timestamp(history.last_disconnected_at) if history is not None else None, - "last_disconnect_reason": history.last_disconnect_reason if history is not None else None, - } - - -def _strava_oauth_enabled(settings: Settings) -> bool: - return bool(getattr(settings, "strava_oauth_enabled", False)) - - -def _whoop_oauth_enabled(settings: Settings) -> bool: - return bool(getattr(settings, "whoop_oauth_enabled", False)) - - -def _strava_configured(settings: Settings) -> bool: - return all( - bool(str(getattr(settings, field, "") or "").strip()) - for field in ( - "strava_oauth_client_id", - "strava_oauth_client_secret", - "strava_oauth_redirect_uri", - ) - ) - - -def _strava_refresh_configured(settings: Settings) -> bool: - return all( - bool(str(getattr(settings, field, "") or "").strip()) - for field in ( - "strava_oauth_client_id", - "strava_oauth_client_secret", - ) - ) - - -def _whoop_configured(settings: Settings) -> bool: - return all( - bool(str(getattr(settings, field, "") or "").strip()) - for field in ( - "whoop_oauth_client_id", - "whoop_oauth_client_secret", - "whoop_oauth_redirect_uri", - ) - ) - - -def _whoop_refresh_configured(settings: Settings) -> bool: - return all( - bool(str(getattr(settings, field, "") or "").strip()) - for field in ( - "whoop_oauth_client_id", - "whoop_oauth_client_secret", - ) - ) - - -def _oauth_session_state( - session: OAuthSession | None, - *, - history: IntegrationConnection | None, - now: datetime, - started_state: ProviderConnectionState, - callback_failed_state: ProviderConnectionState, -) -> ProviderConnectionState | None: - if session is None: - return None - if ( - session.used_at is not None - and history is not None - and history.last_disconnected_at is not None - and history.last_disconnected_at.astimezone(UTC) >= session.used_at.astimezone(UTC) - ): - return None - if session.used_at is not None: - return callback_failed_state - if session.expires_at.astimezone(UTC) > now: - return started_state - return None - - -def _session_attention_message(provider: str, state: ProviderConnectionState | None) -> str | None: - if state == "started": - return _STRAVA_STARTED if provider == "strava" else _WHOOP_STARTED - if state == "callback_failed": - return _STRAVA_CALLBACK_FAILED if provider == "strava" else _WHOOP_CALLBACK_FAILED - return None - - -def _join_provider_names(names: list[str]) -> str: - if not names: - return "" - if len(names) == 1: - return names[0] - if len(names) == 2: - return f"{names[0]} and {names[1]}" - return ", ".join(names[:-1]) + f", and {names[-1]}" - - -def _scope_tokens(scope: str | None) -> set[str]: - normalized_scope = str(scope or "").replace(",", " ") - return {token.strip() for token in normalized_scope.split() if token.strip()} - - -def _attention_provider_names(status: IntegrationsStatus) -> list[str]: - names: list[str] = [] - if status.strava.state == "attention_needed": - names.append("Strava") - if status.whoop.state == "attention_needed": - names.append("WHOOP") - return names - - -def attention_messages(status: IntegrationsStatus) -> list[str]: - messages: list[str] = [] - for provider_status in (status.strava, status.whoop): - if provider_status.state != "attention_needed": - continue - message = str(provider_status.attention_message or "").strip() - if message and message not in messages: - messages.append(message) - return messages - - -def _previously_connected_provider_names(status: IntegrationsStatus) -> list[str]: - names: list[str] = [] - if not status.strava.linked and status.strava.ever_connected: - names.append("Strava") - if not status.whoop.linked and status.whoop.ever_connected: - names.append("WHOOP") - return names - - -def _build_strava_status( - *, - strava: StravaCredentials | None, - history: IntegrationConnection | None, - oauth_session: OAuthSession | None, - settings: Settings, - now: datetime, -) -> StravaIntegrationStatus: - oauth_enabled = _strava_oauth_enabled(settings) - configured = _strava_configured(settings) - if strava is None: - session_state = _oauth_session_state( - oauth_session, - history=history, - now=now, - started_state="started", - callback_failed_state="callback_failed", - ) - if session_state is not None: - return StravaIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state=session_state, - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_session_attention_message("strava", session_state), - ) - if not oauth_enabled: - return StravaIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="disconnected", - connection_state="disabled", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_DISABLED, - ) - if not configured: - return StravaIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="unconfigured", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_UNCONFIGURED, - ) - return StravaIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="disconnected", - connection_state="disconnected", - configured=configured, - oauth_enabled=oauth_enabled, - ) - - expires_at = strava.expires_at.astimezone(UTC) if strava.expires_at is not None else None - expires_at_iso = expires_at.isoformat() if expires_at is not None else None - - if not strava.encrypted_access_token: - return StravaIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="token_missing", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_TOKEN_MISSING, - athlete_id=strava.strava_athlete_id, - expires_at=expires_at_iso, - scope=strava.scope or None, - ) - - if not _REQUIRED_STRAVA_SCOPES.issubset(_scope_tokens(strava.scope)): - return StravaIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="partial_permissions", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_SCOPE_MISSING, - athlete_id=strava.strava_athlete_id, - expires_at=expires_at_iso, - scope=strava.scope or None, - ) - - if expires_at is not None and expires_at <= now: - if not strava.encrypted_refresh_token: - return StravaIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="token_expired", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_REFRESH_MISSING, - athlete_id=strava.strava_athlete_id, - expires_at=expires_at_iso, - scope=strava.scope or None, - ) - if not _strava_refresh_configured(settings): - return StravaIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="stale", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_STRAVA_REFRESH_UNCONFIGURED, - athlete_id=strava.strava_athlete_id, - expires_at=expires_at_iso, - scope=strava.scope or None, - ) - - return StravaIntegrationStatus( - linked=True, - **_history_payload(history), - connected=True, - operational=True, - state="connected", - connection_state="connected_usable", - configured=configured, - oauth_enabled=oauth_enabled, - athlete_id=strava.strava_athlete_id, - expires_at=expires_at_iso, - scope=strava.scope or None, - ) - - -def _build_whoop_status( - *, - whoop: WhoopCredentials | None, - history: IntegrationConnection | None, - oauth_session: OAuthSession | None, - settings: Settings, - now: datetime, -) -> WhoopIntegrationStatus: - oauth_enabled = _whoop_oauth_enabled(settings) - configured = _whoop_configured(settings) - if whoop is None: - session_state = _oauth_session_state( - oauth_session, - history=history, - now=now, - started_state="started", - callback_failed_state="callback_failed", - ) - if session_state is not None: - return WhoopIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state=session_state, - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_session_attention_message("whoop", session_state), - ) - if not oauth_enabled: - return WhoopIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="disconnected", - connection_state="disabled", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_WHOOP_DISABLED, - ) - if not configured: - return WhoopIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="unconfigured", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_WHOOP_UNCONFIGURED, - ) - return WhoopIntegrationStatus( - linked=False, - **_history_payload(history), - connected=False, - operational=False, - state="disconnected", - connection_state="disconnected", - configured=configured, - oauth_enabled=oauth_enabled, - ) - - expires_at = whoop.expires_at.astimezone(UTC) if whoop.expires_at is not None else None - expires_at_iso = expires_at.isoformat() if expires_at is not None else None - - if not whoop.encrypted_access_token: - return WhoopIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="token_missing", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_WHOOP_TOKEN_MISSING, - whoop_user_id=whoop.whoop_user_id, - expires_at=expires_at_iso, - scope=whoop.scope or None, - ) - - if expires_at is not None and expires_at <= now: - if not whoop.encrypted_refresh_token: - return WhoopIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="token_expired", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_WHOOP_REFRESH_MISSING, - whoop_user_id=whoop.whoop_user_id, - expires_at=expires_at_iso, - scope=whoop.scope or None, - ) - if not _whoop_refresh_configured(settings): - return WhoopIntegrationStatus( - linked=True, - **_history_payload(history), - connected=False, - operational=False, - state="attention_needed", - connection_state="stale", - configured=configured, - oauth_enabled=oauth_enabled, - attention_message=_WHOOP_REFRESH_UNCONFIGURED, - whoop_user_id=whoop.whoop_user_id, - expires_at=expires_at_iso, - scope=whoop.scope or None, - ) - - return WhoopIntegrationStatus( - linked=True, - **_history_payload(history), - connected=True, - operational=True, - state="connected", - connection_state="connected_usable", - configured=configured, - oauth_enabled=oauth_enabled, - whoop_user_id=whoop.whoop_user_id, - expires_at=expires_at_iso, - scope=whoop.scope or None, - ) - - -def build_integrations_status( - *, - settings: Settings, - crypto_service: Any | None = None, - strava: StravaCredentials | None = None, - whoop: WhoopCredentials | None = None, - strava_history: IntegrationConnection | None = None, - whoop_history: IntegrationConnection | None = None, - strava_oauth_session: OAuthSession | None = None, - whoop_oauth_session: OAuthSession | None = None, - now: datetime | None = None, -) -> IntegrationsStatus: - del crypto_service - resolved_now = now.astimezone(UTC) if now is not None else datetime.now(UTC) - return IntegrationsStatus( - strava=_build_strava_status( - strava=strava, - history=strava_history, - oauth_session=strava_oauth_session, - settings=settings, - now=resolved_now, - ), - whoop=_build_whoop_status( - whoop=whoop, - history=whoop_history, - oauth_session=whoop_oauth_session, - settings=settings, - now=resolved_now, - ), - ) - - -async def _load_latest_oauth_session( - db: AsyncSession, - *, - user_id: uuid.UUID, - provider: str, -) -> OAuthSession | None: - row = await db.execute( - select(OAuthSession) - .where( - OAuthSession.user_id == user_id, - OAuthSession.provider == provider, - ) - .order_by(OAuthSession.created_at.desc()) - .limit(1) - ) - return row.scalar_one_or_none() - - -async def load_integrations_status( - db: AsyncSession, - *, - user_id: uuid.UUID, - settings: Settings | None = None, - now: datetime | None = None, -) -> IntegrationsStatus: - strava_row = await db.execute(select(StravaCredentials).where(StravaCredentials.user_id == user_id)) - whoop_row = await db.execute(select(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - strava_session = await _load_latest_oauth_session(db, user_id=user_id, provider="strava") - whoop_session = await _load_latest_oauth_session(db, user_id=user_id, provider="whoop") - history_map = await get_connection_history_map(db, user_id=user_id) - - resolved_settings = settings or get_settings() - return build_integrations_status( - settings=resolved_settings, - strava=strava_row.scalar_one_or_none(), - whoop=whoop_row.scalar_one_or_none(), - strava_history=history_map.get("strava"), - whoop_history=history_map.get("whoop"), - strava_oauth_session=strava_session, - whoop_oauth_session=whoop_session, - now=now, - ) - - -def has_operational_training_provider(status: IntegrationsStatus) -> bool: - return status.strava.operational or status.whoop.operational - - -def has_linked_training_provider(status: IntegrationsStatus) -> bool: - return status.strava.linked or status.whoop.linked - - -def has_ever_connected_training_provider(status: IntegrationsStatus) -> bool: - return status.strava.ever_connected or status.whoop.ever_connected - - -def training_provider_requirement_message(status: IntegrationsStatus) -> str: - if has_operational_training_provider(status): - return "" - attention_names = _attention_provider_names(status) - if attention_names: - subject = _join_provider_names(attention_names) - verb = "needs" if len(attention_names) == 1 else "need" - return f"{subject} {verb} attention before a run can start. Check integration settings." - disconnected_names = _previously_connected_provider_names(status) - if disconnected_names: - subject = _join_provider_names(disconnected_names) - verb = "was" if len(disconnected_names) == 1 else "were" - noun = "it" if len(disconnected_names) == 1 else "a training source" - return f"{subject} {verb} disconnected. Reconnect {noun} in Settings before starting a run." - return "No training data source connected. Connect a supported training source first." - - -def training_provider_block_message(status: IntegrationsStatus) -> str: - if has_operational_training_provider(status): - return "" - messages = attention_messages(status) - if messages: - return " ".join(messages) - return training_provider_requirement_message(status) - - -def training_provider_notice_message(status: IntegrationsStatus) -> str | None: - messages = attention_messages(status) - if messages: - return " ".join(messages) - if not has_operational_training_provider(status): - message = training_provider_requirement_message(status) - return message or None - return None diff --git a/api/services/local_readiness.py b/api/services/local_readiness.py index 84addc2..e5d8c3a 100644 --- a/api/services/local_readiness.py +++ b/api/services/local_readiness.py @@ -3,13 +3,8 @@ import os from collections.abc import Mapping -from core.config import AIMode, get_config - def required_llm_provider_key_names() -> tuple[str, ...]: - config = get_config() - if config.ai_mode == AIMode.ANTHROPIC: - return ("ANTHROPIC_API_KEY",) return ("OPENAI_API_KEY",) diff --git a/api/services/local_usage/limits.py b/api/services/local_usage/limits.py index e116593..4af4b88 100644 --- a/api/services/local_usage/limits.py +++ b/api/services/local_usage/limits.py @@ -14,10 +14,8 @@ from api.models.active_weekly_plan import ActiveWeeklyPlan from api.models.job import AnalysisJob, JobStatus from api.models.local_usage import LocalUsageEvent, LocalUsagePlanOverride -from api.services.full_run_policy import ( - evaluate_full_run_availability, - get_latest_full_run_created_at, -) +from api.services.analysis_attempts import get_attempt_started_at +from api.services.full_run_policy import get_latest_full_run_created_at from api.services.local_usage.schemas import ( CoachMessageStatus, LocalUsageFeatures, @@ -38,7 +36,6 @@ ) from api.services.local_usage_plans import ( LOCAL_DEFAULT_USAGE_PLAN, - LOCAL_EXTENDED_USAGE_PLAN, LocalUsagePlan, get_local_usage_plan_definition, ) @@ -80,14 +77,14 @@ class InitialDraftPlanStatus: @dataclass(frozen=True, init=False) class PlanGenerationAccess: - mode: Literal["dev_bypass", "extended", "free"] + mode: Literal["dev_bypass", "extended", "free", "free_initial"] usage_context: LocalUsageContext | None = None initial_draft_claim_source_id: str | None = None def __init__( self, *, - mode: Literal["dev_bypass", "extended", "free"], + mode: Literal["dev_bypass", "extended", "free", "free_initial"], usage_context: LocalUsageContext | None = None, initial_draft_claim_source_id: str | None = None, ): @@ -381,7 +378,8 @@ async def _repair_stale_initial_draft_plan_claim( return False current_time = _now_utc(now) - age_seconds = (current_time - _now_utc(job.created_at)).total_seconds() + attempt_started_at = get_attempt_started_at(job.config, fallback=job.created_at) + age_seconds = (current_time - _now_utc(attempt_started_at)).total_seconds() if age_seconds <= stale_threshold_seconds: return False @@ -396,7 +394,13 @@ async def _find_active_plan_generation_job(db: AsyncSession, *, user_id: uuid.UU select(AnalysisJob) .where( AnalysisJob.user_id == user_id, - AnalysisJob.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)), + AnalysisJob.status.in_( + ( + JobStatus.PENDING.value, + JobStatus.RUNNING.value, + JobStatus.AWAITING_INPUT.value, + ) + ), ) .order_by(AnalysisJob.created_at.desc()) .limit(1) @@ -410,45 +414,17 @@ async def ensure_plan_generation_available( user_id: uuid.UUID, now: datetime | None = None, ) -> PlanGenerationAccess: - settings = get_settings() - if is_usage_safety_bypass_enabled(settings): - return PlanGenerationAccess(mode="dev_bypass", usage_context=None) - active_job = await _find_active_plan_generation_job(db, user_id=user_id) if active_job is not None: - raise HTTPException(status_code=409, detail="Plan generation is already running.") - - context = await get_local_usage_context(db, user_id=user_id) - plan = context.effective_plan - cooldown_days = plan.plan_generation_cooldown_days - cooldown_interval = timedelta(days=cooldown_days) - availability = await evaluate_full_run_availability( - db, - user_id=user_id, - now=now, - min_interval=cooldown_interval, - ) - - if availability.allowed: - return PlanGenerationAccess( - mode="extended" if context.has_access else "free", - usage_context=context, - ) - - next_allowed_at = availability.next_allowed_at.astimezone(UTC).isoformat() if availability.next_allowed_at else None - if not context.has_access: raise HTTPException( - status_code=429, - detail=( - f"Local plan generation is limited to once every {cooldown_days} days. " - f"Next allowed at {next_allowed_at}." - ), + status_code=409, + detail="A plan generation run is already active or waiting for your clarification.", ) + last_run_at = await get_latest_full_run_created_at(db, user_id=user_id) return PlanGenerationAccess( - mode="extended", - usage_context=context, - initial_draft_claim_source_id=None, + mode="free_initial" if last_run_at is None else "free", + usage_context=None, ) @@ -789,8 +765,6 @@ async def build_local_usage_status_snapshot( plan_generation=PlanGenerationStatus( allowed=True, last_generated_at=None, - next_allowed_at=None, - cooldown_days=LOCAL_EXTENDED_USAGE_PLAN.plan_generation_cooldown_days, ), adaptive_updates=await get_adaptive_update_usage(db, user_id=user_id, now=current_time), daily_sync=await get_daily_sync_usage(db, user_id=user_id, now=current_time), @@ -805,12 +779,7 @@ async def build_local_usage_status_snapshot( context = await get_local_usage_context(db, user_id=user_id) plan_override = context.plan_override plan = context.effective_plan - cooldown_days = plan.plan_generation_cooldown_days - cooldown_interval = timedelta(days=cooldown_days) last_full_run_at = await get_latest_full_run_created_at(db, user_id=user_id) - availability = await evaluate_full_run_availability( - db, user_id=user_id, now=current_time, min_interval=cooldown_interval - ) current_period_start, current_period_end = _period_window_for_plan_override(plan_override, now=current_time) return LocalUsageStatusSnapshot( @@ -829,10 +798,8 @@ async def build_local_usage_status_snapshot( current_period_start=current_period_start, current_period_end=current_period_end, plan_generation=PlanGenerationStatus( - allowed=availability.allowed, + allowed=True, last_generated_at=last_full_run_at, - next_allowed_at=availability.next_allowed_at, - cooldown_days=cooldown_days, ), adaptive_updates=await get_adaptive_update_usage(db, user_id=user_id, context=context, now=current_time), daily_sync=await get_daily_sync_usage(db, user_id=user_id, context=context, now=current_time), diff --git a/api/services/local_usage/schemas.py b/api/services/local_usage/schemas.py index a9adaee..351c6d0 100644 --- a/api/services/local_usage/schemas.py +++ b/api/services/local_usage/schemas.py @@ -7,8 +7,6 @@ class PlanGenerationStatus(BaseModel): allowed: bool last_generated_at: datetime | None = None - next_allowed_at: datetime | None = None - cooldown_days: int class UsageWindow(BaseModel): diff --git a/api/services/local_usage/usage.py b/api/services/local_usage/usage.py index 376f384..6f49bc0 100644 --- a/api/services/local_usage/usage.py +++ b/api/services/local_usage/usage.py @@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.models.local_usage import LocalUsageCounter, LocalUsageEvent +from api.services import local_usage_features from api.services.local_usage.schemas import UsageWindow -FEATURE_PLAN_GENERATION = "plan_generation" -FEATURE_FULL_RUN = FEATURE_PLAN_GENERATION +FEATURE_FULL_RUN = local_usage_features.FEATURE_FULL_RUN +FEATURE_PLAN_GENERATION = local_usage_features.FEATURE_PLAN_GENERATION FEATURE_ADAPTIVE_UPDATE = "adaptive_update" FEATURE_DAILY_SYNC = "daily_sync" FEATURE_COACH_TURN = "coach_turn" diff --git a/api/services/local_usage_features.py b/api/services/local_usage_features.py new file mode 100644 index 0000000..d06d623 --- /dev/null +++ b/api/services/local_usage_features.py @@ -0,0 +1,2 @@ +FEATURE_PLAN_GENERATION = "plan_generation" +FEATURE_FULL_RUN = FEATURE_PLAN_GENERATION diff --git a/api/services/local_usage_plans.py b/api/services/local_usage_plans.py index e96ada7..427f581 100644 --- a/api/services/local_usage_plans.py +++ b/api/services/local_usage_plans.py @@ -11,7 +11,6 @@ class LocalUsagePlan: plan_key: str plan_name: str - plan_generation_cooldown_days: int daily_sync_limit: int coach_turn_daily_limit: int weekly_recap_included: bool @@ -23,7 +22,6 @@ class LocalUsagePlan: LOCAL_DEFAULT_USAGE_PLAN = LocalUsagePlan( plan_key=LOCAL_DEFAULT_USAGE_PLAN_KEY, plan_name="Local default", - plan_generation_cooldown_days=28, daily_sync_limit=1, coach_turn_daily_limit=3, weekly_recap_included=True, @@ -35,7 +33,6 @@ class LocalUsagePlan: LOCAL_EXTENDED_USAGE_PLAN = LocalUsagePlan( plan_key=LOCAL_EXTENDED_USAGE_PLAN_KEY, plan_name="Local extended", - plan_generation_cooldown_days=7, daily_sync_limit=1, coach_turn_daily_limit=20, weekly_recap_included=True, diff --git a/api/services/ongoing_providers.py b/api/services/ongoing_providers.py deleted file mode 100644 index 208d678..0000000 --- a/api/services/ongoing_providers.py +++ /dev/null @@ -1,439 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import UTC, date, datetime, time, timedelta -from typing import Protocol, cast - -from fastapi import HTTPException -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from api.models.credentials import StravaCredentials, WhoopCredentials -from api.services.strava_tokens import ensure_valid_access_token as ensure_valid_strava_access_token -from api.services.whoop_tokens import ensure_valid_access_token -from services.strava import StravaApiClient -from services.whoop import WhoopApiClient - - -def _utc_today() -> date: - return datetime.now(UTC).date() - - -def _safe_float(value: object) -> float | None: - if value is None: - return None - try: - return float(value) # type: ignore[arg-type] - except (TypeError, ValueError): - return None - - -def _whoop_window_datetimes(date_from: date, date_to: date) -> tuple[datetime, datetime]: - start_dt = datetime.combine(date_from, time.min, tzinfo=UTC) - end_dt = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=UTC) - return start_dt, end_dt - - -def _strava_window_timestamps(date_from: date, date_to: date) -> tuple[int, int]: - start_dt = datetime.combine(date_from, time.min, tzinfo=UTC) - end_dt = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=UTC) - return int(start_dt.timestamp()), int(end_dt.timestamp()) - - -def _strava_activity_date_key(activity: dict) -> str | None: - raw_start = activity.get("start_date_local") or activity.get("start_date") - if not isinstance(raw_start, str) or not raw_start.strip(): - return None - return raw_start[:10] - - -def _strava_activity_load_value(activity: dict) -> float | None: - for candidate in (activity.get("relative_effort"), activity.get("suffer_score")): - parsed = _safe_float(candidate) - if parsed is not None: - return parsed - return None - - -def _strava_sport_name(activity: dict) -> str: - return str(activity.get("sport_type") or activity.get("type") or "").strip() - - -class OngoingTrainingProvider(Protocol): - async def get_recent_activities( - self, - date_from: date, - date_to: date, - sport_filters: list[str] | None = None, - ) -> list[dict]: - ... - - async def get_training_load_history(self, days: int) -> list[dict]: - ... - - async def get_recovery_readiness_signals(self, days: int) -> dict: - ... - - async def get_activity_detail(self, activity_id: int | str) -> dict | None: - ... - - -class StravaProvider: - def __init__(self, *, db: AsyncSession, user_id): - self._db = db - self._user_id = user_id - self._access_token: str | None = None - self._access_token_error: tuple[int, str] | None = None - self._access_token_lock = asyncio.Lock() - - async def _get_access_token(self) -> str: - cached_access_token = self._access_token - if cached_access_token is not None: - return cached_access_token - cached_error = self._access_token_error - if cached_error is not None: - status_code, detail = cached_error - raise HTTPException(status_code=status_code, detail=detail) - - async with self._access_token_lock: - access_token = self._access_token - cached_error = self._access_token_error - if access_token is None and cached_error is None: - try: - access_token = await ensure_valid_strava_access_token(self._db, user_id=self._user_id) - except HTTPException as exc: - detail = str(exc.detail) - cached_error = (exc.status_code, detail) - self._access_token_error = cached_error - raise HTTPException(status_code=exc.status_code, detail=detail) from exc - - self._access_token = access_token - - if access_token is not None: - return access_token - - status_code, detail = cached_error or (500, "Strava access token could not be resolved.") - raise HTTPException(status_code=status_code, detail=detail) - - def _list_activities_sync(self, *, access_token: str, after: int, before: int) -> list[dict]: - client = StravaApiClient(access_token=access_token) - try: - activities: list[dict] = [] - page = 1 - per_page = 100 - while True: - batch = client.list_activities(page=page, per_page=per_page, after=after, before=before) - if not batch: - break - activities.extend(batch) - if len(batch) < per_page: - break - page += 1 - return activities - finally: - client.close() - - def _get_activity_sync(self, *, access_token: str, activity_id: str) -> dict: - client = StravaApiClient(access_token=access_token) - try: - return client.get_activity(activity_id) - finally: - client.close() - - async def get_recent_activities( - self, - date_from: date, - date_to: date, - sport_filters: list[str] | None = None, - ) -> list[dict]: - after, before = _strava_window_timestamps(date_from, date_to) - access_token = await self._get_access_token() - activities = await asyncio.to_thread( - self._list_activities_sync, - access_token=access_token, - after=after, - before=before, - ) - - if not sport_filters: - return activities - - allowed = {sport.strip().lower() for sport in sport_filters if sport.strip()} - return [ - activity - for activity in activities - if _strava_sport_name(activity).lower() in allowed - ] - - async def get_training_load_history(self, days: int) -> list[dict]: - safe_days = max(1, min(days, 180)) - end_date = _utc_today() - start_date = end_date - timedelta(days=safe_days - 1) - activities = await self.get_recent_activities(start_date, end_date) - - daily_totals: dict[str, dict[str, object]] = {} - for activity in activities: - date_key = _strava_activity_date_key(activity) - if not date_key: - continue - bucket = daily_totals.setdefault( - date_key, - { - "date": date_key, - "activity_count": 0, - "relative_effort_total": 0.0, - "suffer_score_total": 0.0, - "moving_time_minutes_total": 0.0, - "distance_m_total": 0.0, - "_has_relative_effort": False, - "_has_suffer_score": False, - }, - ) - bucket["activity_count"] = cast("int", bucket["activity_count"]) + 1 - - relative_effort = _safe_float(activity.get("relative_effort")) - if relative_effort is not None: - bucket["relative_effort_total"] = cast("float", bucket["relative_effort_total"]) + relative_effort - bucket["_has_relative_effort"] = True - - suffer_score = _safe_float(activity.get("suffer_score")) - if suffer_score is not None: - bucket["suffer_score_total"] = cast("float", bucket["suffer_score_total"]) + suffer_score - bucket["_has_suffer_score"] = True - - moving_time = _safe_float(activity.get("moving_time")) - if moving_time is not None: - bucket["moving_time_minutes_total"] = ( - cast("float", bucket["moving_time_minutes_total"]) + (moving_time / 60.0) - ) - - distance_m = _safe_float(activity.get("distance")) - if distance_m is not None: - bucket["distance_m_total"] = cast("float", bucket["distance_m_total"]) + distance_m - - payload: list[dict] = [] - for date_key in sorted(daily_totals.keys()): - bucket = dict(daily_totals[date_key]) - has_relative_effort = bool(bucket.pop("_has_relative_effort", False)) - has_suffer_score = bool(bucket.pop("_has_suffer_score", False)) - load_value = None - load_type = "strava_activity_count" - if has_relative_effort: - load_value = bucket.get("relative_effort_total") - load_type = "strava_relative_effort" - elif has_suffer_score: - load_value = bucket.get("suffer_score_total") - load_type = "strava_suffer_score" - bucket["load_type"] = load_type - bucket["load_value"] = load_value - payload.append(bucket) - return payload - - async def get_recovery_readiness_signals(self, days: int) -> dict: - safe_days = max(1, min(days, 30)) - end_date = _utc_today() - start_date = end_date - timedelta(days=safe_days - 1) - activities = await self.get_recent_activities(start_date, end_date) - - total_distance_m = 0.0 - total_moving_time_seconds = 0.0 - total_relative_effort = 0.0 - total_suffer_score = 0.0 - has_relative_effort = False - has_suffer_score = False - for activity in activities: - distance_m = _safe_float(activity.get("distance")) - if distance_m is not None: - total_distance_m += distance_m - moving_time = _safe_float(activity.get("moving_time")) - if moving_time is not None: - total_moving_time_seconds += moving_time - relative_effort = _safe_float(activity.get("relative_effort")) - if relative_effort is not None: - total_relative_effort += relative_effort - has_relative_effort = True - suffer_score = _safe_float(activity.get("suffer_score")) - if suffer_score is not None: - total_suffer_score += suffer_score - has_suffer_score = True - - return { - "window_days": safe_days, - "as_of_utc": datetime.now(UTC).isoformat(), - "recent_activities": activities, - "activity_summary": { - "activity_count": len(activities), - "distance_km_total": round(total_distance_m / 1000.0, 2) if total_distance_m else 0.0, - "moving_time_minutes_total": round(total_moving_time_seconds / 60.0, 1) - if total_moving_time_seconds - else 0.0, - "relative_effort_total": total_relative_effort if has_relative_effort else None, - "suffer_score_total": total_suffer_score if has_suffer_score else None, - }, - } - - async def get_activity_detail(self, activity_id: int | str) -> dict | None: - raw_activity_id = str(activity_id).strip() - if not raw_activity_id: - return None - access_token = await self._get_access_token() - return await asyncio.to_thread( - self._get_activity_sync, - access_token=access_token, - activity_id=raw_activity_id, - ) - - -class WhoopProvider: - def __init__(self, *, db: AsyncSession, user_id): - self._db = db - self._user_id = user_id - self._access_token: str | None = None - self._access_token_error: tuple[int, str] | None = None - self._access_token_lock = asyncio.Lock() - - async def _get_access_token(self) -> str: - cached_access_token = self._access_token - if cached_access_token is not None: - return cached_access_token - cached_error = self._access_token_error - if cached_error is not None: - status_code, detail = cached_error - raise HTTPException(status_code=status_code, detail=detail) - - async with self._access_token_lock: - access_token = self._access_token - cached_error = self._access_token_error - if access_token is None and cached_error is None: - try: - access_token = await ensure_valid_access_token(self._db, user_id=self._user_id) - except HTTPException as exc: - detail = str(exc.detail) - cached_error = (exc.status_code, detail) - self._access_token_error = cached_error - raise HTTPException(status_code=exc.status_code, detail=detail) from exc - - self._access_token = access_token - - if access_token is not None: - return access_token - - status_code, detail = cached_error or (500, "WHOOP access token could not be resolved.") - raise HTTPException(status_code=status_code, detail=detail) - - def _list_workouts_sync(self, *, access_token: str, start: datetime, end: datetime) -> list[dict]: - client = WhoopApiClient(access_token=access_token) - try: - return client.list_workouts(start=start, end=end) - finally: - client.close() - - def _list_cycles_sync(self, *, access_token: str, start: datetime, end: datetime) -> list[dict]: - client = WhoopApiClient(access_token=access_token) - try: - return client.list_cycles(start=start, end=end) - finally: - client.close() - - def _list_recoveries_sync(self, *, access_token: str, start: datetime, end: datetime) -> list[dict]: - client = WhoopApiClient(access_token=access_token) - try: - return client.list_recoveries(start=start, end=end) - finally: - client.close() - - def _list_sleeps_sync(self, *, access_token: str, start: datetime, end: datetime) -> list[dict]: - client = WhoopApiClient(access_token=access_token) - try: - return client.list_sleeps(start=start, end=end) - finally: - client.close() - - def _get_workout_sync(self, *, access_token: str, workout_id: str) -> dict: - client = WhoopApiClient(access_token=access_token) - try: - return client.get_workout(workout_id) - finally: - client.close() - - async def get_recent_activities( - self, - date_from: date, - date_to: date, - sport_filters: list[str] | None = None, - ) -> list[dict]: - start_dt, end_dt = _whoop_window_datetimes(date_from, date_to) - access_token = await self._get_access_token() - workouts = await asyncio.to_thread( - self._list_workouts_sync, - access_token=access_token, - start=start_dt, - end=end_dt, - ) - - if not sport_filters: - return workouts - - allowed = {sport.strip().lower() for sport in sport_filters if sport.strip()} - return [ - workout - for workout in workouts - if str(workout.get("sport_name", "")).strip().lower() in allowed - ] - - async def get_training_load_history(self, days: int) -> list[dict]: - safe_days = max(1, min(days, 180)) - end_date = _utc_today() - start_date = end_date - timedelta(days=safe_days - 1) - start_dt, end_dt = _whoop_window_datetimes(start_date, end_date) - access_token = await self._get_access_token() - return await asyncio.to_thread( - self._list_cycles_sync, - access_token=access_token, - start=start_dt, - end=end_dt, - ) - - async def get_recovery_readiness_signals(self, days: int) -> dict: - safe_days = max(1, min(days, 30)) - end_date = _utc_today() - start_date = end_date - timedelta(days=safe_days - 1) - start_dt, end_dt = _whoop_window_datetimes(start_date, end_date) - access_token = await self._get_access_token() - - recoveries, sleeps, cycles = await asyncio.gather( - asyncio.to_thread(self._list_recoveries_sync, access_token=access_token, start=start_dt, end=end_dt), - asyncio.to_thread(self._list_sleeps_sync, access_token=access_token, start=start_dt, end=end_dt), - asyncio.to_thread(self._list_cycles_sync, access_token=access_token, start=start_dt, end=end_dt), - ) - - return { - "window_days": safe_days, - "as_of_utc": datetime.now(UTC).isoformat(), - "recoveries": recoveries, - "sleeps": sleeps, - "cycles": cycles, - } - - async def get_activity_detail(self, activity_id: int | str) -> dict | None: - workout_id = str(activity_id).strip() - if not workout_id: - return None - access_token = await self._get_access_token() - return await asyncio.to_thread(self._get_workout_sync, access_token=access_token, workout_id=workout_id) - - -async def build_ongoing_strava_provider(db: AsyncSession, *, user_id) -> OngoingTrainingProvider: - creds_row = await db.execute(select(StravaCredentials).where(StravaCredentials.user_id == user_id)) - creds = creds_row.scalar_one_or_none() - if not creds: - raise HTTPException(status_code=404, detail="Strava credentials not configured") - return StravaProvider(db=db, user_id=user_id) - - -async def build_ongoing_whoop_provider(db: AsyncSession, *, user_id) -> OngoingTrainingProvider: - creds_row = await db.execute(select(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - creds = creds_row.scalar_one_or_none() - if not creds: - raise HTTPException(status_code=404, detail="WHOOP credentials not configured") - return WhoopProvider(db=db, user_id=user_id) diff --git a/api/services/ongoing_tools.py b/api/services/ongoing_tools.py index 4aff440..3ce0491 100644 --- a/api/services/ongoing_tools.py +++ b/api/services/ongoing_tools.py @@ -1,74 +1,21 @@ from __future__ import annotations import asyncio +from collections.abc import Collection from contextlib import asynccontextmanager -from datetime import UTC, date, datetime, timedelta -from statistics import mean +from datetime import UTC, date, datetime from time import perf_counter -from typing import Literal, cast -from fastapi import HTTPException from langchain_core.tools import tool from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from api.models.active_analysis import ActiveAnalysis from api.models.active_season_plan import ActiveSeasonPlan from api.models.active_weekly_plan import ActiveWeeklyPlan +from api.models.athlete_profile import AthleteProfile from api.models.competition import Competition from api.models.user import User from api.services.coach_memory_metadata import derive_memory_freshness, extract_transient_state_notes -from api.services.evidence_profile import build_evidence_profile, build_evidence_sources -from api.services.ongoing_providers import ( - OngoingTrainingProvider, - build_ongoing_strava_provider, - build_ongoing_whoop_provider, -) - - -def _safe_float(value: object) -> float | None: - if value is None: - return None - try: - return float(value) # type: ignore[arg-type] - except (TypeError, ValueError): - return None - - -def _extract_load(entry: dict) -> float | None: - candidates = ( - entry.get("load"), - entry.get("training_load"), - entry.get("trainingLoad"), - entry.get("daily_training_load"), - entry.get("dailyTrainingLoad"), - entry.get("load_value"), - ) - for candidate in candidates: - parsed = _safe_float(candidate) - if parsed is not None: - return parsed - return None - - -def _normalize_activity_id(activity_id: object) -> str | None: - if activity_id is None: - return None - value = str(activity_id).strip() - return value if value else None - - -def _activity_date(activity: dict) -> date | None: - raw_start = activity.get("start_time") or activity.get("startTime") - if not isinstance(raw_start, str): - return None - trimmed = raw_start.strip() - if not trimmed: - return None - try: - return date.fromisoformat(trimmed[:10]) - except ValueError: - return None def nearest_competition_days(competitions: list[dict], today: date) -> int | None: @@ -78,487 +25,136 @@ def nearest_competition_days(competitions: list[dict], today: date) -> int | Non if not isinstance(raw_date, str) or not raw_date: continue try: - comp_date = date.fromisoformat(raw_date[:10]) + competition_date = date.fromisoformat(raw_date[:10]) except ValueError: continue - delta = (comp_date - today).days + delta = (competition_date - today).days if delta >= 0 and (nearest is None or delta < nearest): nearest = delta return nearest -def _staleness_label(age_days: int | None) -> str: - if age_days is None: - return "unknown" - if age_days <= 7: - return "fresh" - if age_days <= 14: - return "moderate" - return "stale" - - class OngoingToolRegistry: - def __init__( - self, - *, - db: AsyncSession, - user_id, - providers: dict[str, OngoingTrainingProvider], - ): + """Read-only tools over the app's local, athlete-owned sources of truth.""" + + _REGISTERED_TOOL_NAMES = frozenset( + { + "get_current_weekly_plan", + "get_current_season_plan", + "get_upcoming_competitions", + "get_athlete_profile", + } + ) + + def __init__(self, *, db: AsyncSession, user_id): self._db = db self._user_id = user_id - self._providers = providers self._request_cache: dict[str, object] = {} self._tool_usage: dict[str, dict[str, float | int]] = {} - self._activity_index: dict[str, dict] = {} - self._provider_runtime_status: dict[str, dict[str, object]] = { - name: { - "kind": type(provider).__name__, - "available": True, - "last_error": None, - "status_code": None, - } - for name, provider in providers.items() - } - - def _provider_observability(self) -> dict: - snapshot: dict[str, dict[str, object]] = dict(self._provider_runtime_status) - # Include known providers even when disconnected so the agent can reason about gaps. - for name in ("strava", "whoop"): - snapshot.setdefault( - name, - { - "kind": None, - "available": False, - "last_error": None, - "status_code": None, - }, - ) - evidence_sources = build_evidence_sources(provider_status=snapshot) - for name, evidence_source in evidence_sources.items(): - entry = snapshot.setdefault( - name, - { - "kind": None, - "available": False, - "last_error": None, - "status_code": None, - }, - ) - entry["operational"] = evidence_source["operational"] - entry["capabilities"] = evidence_source["capabilities"] - return {"training_providers": snapshot} - - def _current_evidence_profile(self) -> dict[str, object]: - provider_status = self._provider_observability()["training_providers"] - return build_evidence_profile(provider_status=provider_status) - - @staticmethod - def _degraded_provider_error(exc: BaseException) -> HTTPException | None: - if isinstance(exc, HTTPException) and exc.status_code in {401, 404, 409, 502, 503}: - return exc - return None - - def _mark_provider_unavailable(self, *, source_name: str, exc: HTTPException): - provider = self._providers.get(source_name) - self._provider_runtime_status[source_name] = { - "kind": type(provider).__name__ if provider is not None else None, - "available": False, - "last_error": str(exc.detail), - "status_code": exc.status_code, - } + self._db_lock = asyncio.Lock() async def _measure(self, name: str, call): - start = perf_counter() - result = await call() - elapsed_ms = (perf_counter() - start) * 1000.0 - + started_at = perf_counter() + # One registry is request-scoped and intentionally shares its caller's + # AsyncSession. SQLAlchemy sessions may not execute concurrently, while + # LangChain is free to invoke independent tools in parallel. + async with self._db_lock: + result = await call() + elapsed_ms = (perf_counter() - started_at) * 1000.0 current = self._tool_usage.get(name) - if current: + if current is None: + self._tool_usage[name] = {"count": 1, "total_ms": elapsed_ms} + else: current["count"] = int(current["count"]) + 1 current["total_ms"] = float(current["total_ms"]) + elapsed_ms - else: - self._tool_usage[name] = {"count": 1, "total_ms": elapsed_ms} - return result - - def _get_cached(self, cache_key: str) -> object | None: - return self._request_cache.get(cache_key) - - def _set_cached(self, cache_key: str, value: object): - self._request_cache[cache_key] = value - - def _index_activities(self, activities: list[dict]): - for activity in activities: - key = _normalize_activity_id(activity.get("activity_id")) - if key is not None: - self._activity_index[key] = activity - - def _activity_source_id(self, *, source_name: str, item: dict) -> object | None: - if source_name == "strava": - return item.get("id") or item.get("activity_id") or item.get("activityId") - if source_name == "whoop": - return item.get("id") or item.get("activity_id") or item.get("activityId") - return item.get("id") or item.get("activity_id") or item.get("activityId") - - def _apply_strava_activity_defaults(self, payload: dict) -> None: - payload.setdefault("start_time", payload.get("start_date_local") or payload.get("start_date")) - payload.setdefault("activity_type", payload.get("sport_type") or payload.get("type")) - payload.setdefault("activity_name", payload.get("name")) - - def _apply_whoop_activity_defaults(self, payload: dict) -> None: - payload.setdefault("start_time", payload.get("start")) - payload.setdefault("activity_type", payload.get("sport_name")) - payload.setdefault("activity_name", payload.get("sport_name")) - - def _enrich_recent_activity_item(self, *, source_name: str, item: dict) -> dict | None: - raw_id = self._activity_source_id(source_name=source_name, item=item) - if raw_id is None: - return None - composite_id = f"{source_name}:{raw_id}" - payload = dict(item) - payload["source"] = source_name - payload["source_activity_id"] = raw_id - payload["activity_id"] = composite_id - if source_name == "strava": - self._apply_strava_activity_defaults(payload) - if source_name == "whoop": - self._apply_whoop_activity_defaults(payload) - return payload - - async def _load_recent_activities_one( - self, - *, - source_name: str, - provider: OngoingTrainingProvider, - date_from: date, - date_to: date, - sport_filters: list[str] | None, - ) -> list[dict]: - raw_items = await provider.get_recent_activities(date_from, date_to, sport_filters) - enriched: list[dict] = [] - for item in raw_items: - payload = self._enrich_recent_activity_item(source_name=source_name, item=item) - if payload is not None: - enriched.append(payload) - return enriched - - async def _load_recent_activities_merged( - self, - *, - date_from: date, - date_to: date, - sport_filters: list[str] | None, - ) -> list[dict]: - if not self._providers: - return [] - provider_entries = list(self._providers.items()) - tasks = [ - self._load_recent_activities_one( - source_name=source_name, - provider=provider, - date_from=date_from, - date_to=date_to, - sport_filters=sport_filters, - ) - for source_name, provider in provider_entries - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - merged: list[dict] = [] - for (source_name, _provider), batch in zip(provider_entries, results, strict=True): - if isinstance(batch, BaseException): - degraded_error = self._degraded_provider_error(batch) - if degraded_error is not None: - self._mark_provider_unavailable(source_name=source_name, exc=degraded_error) - continue - raise batch - merged.extend(batch) - return merged - - def _parse_activity_id(self, activity_id: int | str) -> tuple[str, str, str] | None: - raw_value = _normalize_activity_id(activity_id) - if raw_value is None: - return None - - if ":" not in raw_value: - return None - source_name, _, raw_id = raw_value.partition(":") - source_name = source_name.strip().lower() - raw_id = raw_id.strip() - - if not raw_id: - return None - - composite_id = f"{source_name}:{raw_id}" - return source_name, raw_id, composite_id - - def _enrich_activity_detail_payload(self, *, source_name: str, raw_id: str, payload: dict) -> dict: - enriched = dict(payload) - enriched["source"] = source_name - enriched["source_activity_id"] = raw_id - enriched["activity_id"] = f"{source_name}:{raw_id}" - if source_name == "strava": - self._apply_strava_activity_defaults(enriched) - if source_name == "whoop": - self._apply_whoop_activity_defaults(enriched) - return enriched - - def _whoop_cycle_day(self, payload: dict) -> str | None: - start = payload.get("start") - if not isinstance(start, str) or not start.strip(): - return None - return start[:10] - - def _enrich_training_load_item(self, *, source_name: str, item: dict) -> dict: - payload = dict(item) - payload["source"] = source_name - - if source_name == "strava": - payload["load_type"] = str(payload.get("load_type") or "strava_relative_effort") - payload["load_value"] = ( - _safe_float(payload.get("load_value")) - or _safe_float(payload.get("relative_effort_total")) - or _safe_float(payload.get("suffer_score_total")) - ) - return payload - - if source_name == "whoop": - score = payload.get("score") if isinstance(payload.get("score"), dict) else {} - strain = _safe_float(score.get("strain")) if isinstance(score, dict) else None - payload["date"] = payload.get("date") or self._whoop_cycle_day(payload) - payload["load_type"] = "whoop_strain_0_21" - payload["load_value"] = strain - return payload - - return payload - - async def _load_training_load_history_one( - self, - *, - source_name: str, - provider: OngoingTrainingProvider, - days: int, - ) -> list[dict]: - raw_items = await provider.get_training_load_history(days) - enriched: list[dict] = [] - for item in raw_items: - payload = self._enrich_training_load_item(source_name=source_name, item=item) - enriched.append(payload) - enriched.sort(key=lambda row: str(row.get("date") or "")) - return enriched - - async def _load_training_load_history_merged(self, *, days: int) -> list[dict]: - if not self._providers: - return [] - provider_entries = list(self._providers.items()) - tasks = [ - self._load_training_load_history_one(source_name=source_name, provider=provider, days=days) - for source_name, provider in provider_entries - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - merged: list[dict] = [] - for (source_name, _provider), batch in zip(provider_entries, results, strict=True): - if isinstance(batch, BaseException): - degraded_error = self._degraded_provider_error(batch) - if degraded_error is not None: - self._mark_provider_unavailable(source_name=source_name, exc=degraded_error) - continue - raise batch - merged.extend(batch) - merged.sort(key=lambda row: (str(row.get("date") or ""), str(row.get("source") or ""))) - return merged - - def _to_strava_activity_summary(self, activity: dict) -> dict: - raw_duration = _safe_float(activity.get("moving_time")) or _safe_float(activity.get("elapsed_time")) - raw_distance = _safe_float(activity.get("distance")) - activity_date = _activity_date(activity) - average_power = ( - activity.get("average_watts") - or activity.get("weighted_average_watts") - or activity.get("average_speed") - ) - - return { - "source": "strava", - "activity_id": activity.get("activity_id"), - "source_activity_id": activity.get("source_activity_id"), - "date": activity_date.isoformat() if activity_date else None, - "activity_type": activity.get("activity_type") or activity.get("activityType"), - "activity_name": activity.get("activity_name") or activity.get("activityName"), - "duration_min": round(raw_duration / 60.0, 1) if raw_duration is not None else None, - "distance_km": round(raw_distance / 1000.0, 2) if raw_distance is not None else None, - "elevation_gain_m": _safe_float(activity.get("total_elevation_gain")), - "average_hr": activity.get("average_heartrate"), - "average_pace_or_power": average_power, - "relative_effort": activity.get("relative_effort"), - "suffer_score": activity.get("suffer_score"), - } - - def _to_whoop_workout_summary(self, workout: dict) -> dict: - start = workout.get("start") - end = workout.get("end") - workout_date = None - if isinstance(start, str) and start.strip(): - try: - workout_date = date.fromisoformat(start[:10]) - except ValueError: - workout_date = None - - duration_min = None - if isinstance(start, str) and isinstance(end, str) and start and end: - try: - start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) - end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) - duration_min = round((end_dt - start_dt).total_seconds() / 60.0, 1) - except ValueError: - duration_min = None - - score_raw = workout.get("score") - score: dict = score_raw if isinstance(score_raw, dict) else {} - distance_m = _safe_float(score.get("distance_meter")) - distance_km = round(distance_m / 1000.0, 2) if distance_m is not None else None - - return { - "source": "whoop", - "activity_id": workout.get("activity_id"), - "source_activity_id": workout.get("source_activity_id"), - "date": workout_date.isoformat() if workout_date else None, - "activity_type": workout.get("sport_name"), - "activity_name": workout.get("sport_name"), - "duration_min": duration_min, - "distance_km": distance_km, - "average_hr": score.get("average_heart_rate"), - "whoop_strain": score.get("strain"), - } - - async def _load_active_analysis(self) -> ActiveAnalysis | None: - cache_key = "active_analysis_row" - cached = self._request_cache.get(cache_key) - if isinstance(cached, ActiveAnalysis) or cached is None: - if cache_key in self._request_cache: - return cached - - async def _load(): - row = await self._db.execute(select(ActiveAnalysis).where(ActiveAnalysis.user_id == self._user_id)) - return row.scalar_one_or_none() - - result = await self._measure("get_active_analysis", _load) - self._request_cache[cache_key] = result return result async def get_current_weekly_plan(self) -> dict: cache_key = "current_weekly_plan" - cached = self._request_cache.get(cache_key) - if cached is not None: - return cached # type: ignore[return-value] + if cache_key in self._request_cache: + return self._request_cache[cache_key] # type: ignore[return-value] async def _load(): - row = await self._db.execute(select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == self._user_id)) - plan = row.scalar_one_or_none() - if not plan: + result = await self._db.execute( + select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == self._user_id) + ) + plan = result.scalar_one_or_none() + if plan is None: return {} payload = dict(plan.plan_data or {}) payload["version"] = plan.version payload["updated_at"] = plan.updated_at.isoformat() return payload - result = await self._measure("get_current_weekly_plan", _load) - self._request_cache[cache_key] = result - return result + payload = await self._measure(cache_key, _load) + self._request_cache[cache_key] = payload + return payload async def get_current_season_plan(self) -> dict: cache_key = "current_season_plan" - cached = self._request_cache.get(cache_key) - if cached is not None: - return cached # type: ignore[return-value] + if cache_key in self._request_cache: + return self._request_cache[cache_key] # type: ignore[return-value] async def _load(): - row = await self._db.execute(select(ActiveSeasonPlan).where(ActiveSeasonPlan.user_id == self._user_id)) - plan = row.scalar_one_or_none() - if not plan: + result = await self._db.execute( + select(ActiveSeasonPlan).where(ActiveSeasonPlan.user_id == self._user_id) + ) + plan = result.scalar_one_or_none() + if plan is None: return {} payload = dict(plan.plan_data or {}) payload["version"] = plan.version payload["updated_at"] = plan.updated_at.isoformat() return payload - result = await self._measure("get_current_season_plan", _load) - self._request_cache[cache_key] = result - return result - - async def get_current_analysis(self) -> dict: - cache_key = "current_analysis" - cached = self._request_cache.get(cache_key) - if cached is not None: - return cached # type: ignore[return-value] - - cached_row = self._request_cache.get("active_analysis_row") - if isinstance(cached_row, ActiveAnalysis): - payload = dict(cached_row.analysis_data or {}) - payload["version"] = cached_row.version - payload["updated_at"] = cached_row.updated_at.isoformat() - self._request_cache[cache_key] = payload - return payload - if "active_analysis_row" in self._request_cache and cached_row is None: - self._request_cache[cache_key] = {} - return {} - - async def _load(): - row = await self._db.execute(select(ActiveAnalysis).where(ActiveAnalysis.user_id == self._user_id)) - analysis = row.scalar_one_or_none() - self._request_cache["active_analysis_row"] = analysis - if not analysis: - return {} - payload = dict(analysis.analysis_data or {}) - payload["version"] = analysis.version - payload["updated_at"] = analysis.updated_at.isoformat() - return payload - - result = await self._measure("get_current_analysis", _load) - self._request_cache[cache_key] = result - return result + payload = await self._measure(cache_key, _load) + self._request_cache[cache_key] = payload + return payload async def get_upcoming_competitions(self) -> list[dict]: cache_key = "upcoming_competitions" - cached = self._request_cache.get(cache_key) - if cached is not None: - return cached # type: ignore[return-value] + if cache_key in self._request_cache: + return self._request_cache[cache_key] # type: ignore[return-value] async def _load(): - row = await self._db.execute( + result = await self._db.execute( select(Competition) .where(Competition.user_id == self._user_id) .order_by(Competition.date.asc().nullslast(), Competition.created_at.asc()) ) - competitions = row.scalars().all() return [ { - "id": str(comp.id), - "name": comp.name, - "date": comp.date.isoformat() if comp.date else None, - "date_text": comp.date_text, - "race_type": comp.race_type, - "priority": comp.priority, - "target_time": comp.target_time, - "notes": comp.notes, + "id": str(competition.id), + "name": competition.name, + "date": competition.date.isoformat() if competition.date else None, + "date_text": competition.date_text, + "race_type": competition.race_type, + "priority": competition.priority, + "target_time": competition.target_time, + "notes": competition.notes, } - for comp in competitions + for competition in result.scalars().all() ] - result = await self._measure("get_upcoming_competitions", _load) - self._request_cache[cache_key] = result - return result + payload = await self._measure(cache_key, _load) + self._request_cache[cache_key] = payload + return payload async def get_athlete_profile(self) -> dict: cache_key = "athlete_profile" - cached = self._request_cache.get(cache_key) - if cached is not None: - return cached # type: ignore[return-value] + if cache_key in self._request_cache: + return self._request_cache[cache_key] # type: ignore[return-value] async def _load(): - row = await self._db.execute(select(User).where(User.id == self._user_id)) - user = row.scalar_one_or_none() + user_result = await self._db.execute(select(User).where(User.id == self._user_id)) + profile_result = await self._db.execute( + select(AthleteProfile.profile).where(AthleteProfile.user_id == self._user_id) + ) + user = user_result.scalar_one_or_none() + canonical_profile = profile_result.scalar_one_or_none() or {} if user is None: return { + "profile": canonical_profile, "memory_summary": "", "athlete_model": {}, "transient_state_notes": [], @@ -566,410 +162,33 @@ async def _load(): "memory_age_days": None, } athlete_model = user.athlete_model or {} - memory_updated_at, memory_age_days = derive_memory_freshness(athlete_model, now=datetime.now(UTC)) - transient_state_notes = extract_transient_state_notes(athlete_model) + memory_updated_at, memory_age_days = derive_memory_freshness( + athlete_model, now=datetime.now(UTC) + ) return { + "profile": canonical_profile, "memory_summary": user.memory_summary or "", "athlete_model": athlete_model, - "transient_state_notes": transient_state_notes, + "transient_state_notes": extract_transient_state_notes(athlete_model), "memory_updated_at": memory_updated_at, "memory_age_days": memory_age_days, } - result = await self._measure("get_athlete_profile", _load) - self._request_cache[cache_key] = result - return result - - async def _get_raw_recent_activities( - self, - *, - date_from: date, - date_to: date, - sport_filters: list[str] | None = None, - ) -> list[dict]: - filter_part = ",".join(sorted(sport_filters or [])) - cache_key = f"recent_activities:{date_from.isoformat()}:{date_to.isoformat()}:{filter_part}" - cached = self._get_cached(cache_key) - if isinstance(cached, list): - activities = cast("list[dict]", cached) - self._index_activities(activities) - return activities - - result = await self._measure( - "get_recent_activities", - lambda: self._load_recent_activities_merged( - date_from=date_from, - date_to=date_to, - sport_filters=sport_filters, - ), - ) - self._set_cached(cache_key, result) - if isinstance(result, list): - self._index_activities([item for item in result if isinstance(item, dict)]) - return result - - async def get_recent_activities( - self, - *, - date_from: date, - date_to: date, - sport_filters: list[str] | None = None, - detail_level: Literal["summary", "full"] = "full", - ) -> list[dict]: - activities = await self._get_raw_recent_activities( - date_from=date_from, - date_to=date_to, - sport_filters=sport_filters, - ) - if detail_level == "summary": - summaries: list[dict] = [] - for activity in activities: - source = activity.get("source") - if source == "whoop": - summaries.append(self._to_whoop_workout_summary(activity)) - else: - summaries.append(self._to_strava_activity_summary(activity)) - return summaries - return activities - - async def get_activity_detail(self, *, activity_id: int | str) -> dict | None: - parsed = self._parse_activity_id(activity_id) - if parsed is None: - return None - source_name, raw_id, composite_id = parsed - - indexed = self._activity_index.get(composite_id) - if isinstance(indexed, dict): - return indexed - - cache_key = f"activity_detail:{composite_id}" - cached = self._get_cached(cache_key) - if cached is not None: - if isinstance(cached, dict): - self._activity_index[composite_id] = cached - return cached # type: ignore[return-value] - - provider = self._providers.get(source_name) - if provider is None: - return None - - result = await self._measure( - "get_activity_detail", - lambda: provider.get_activity_detail(raw_id), - ) - if result is not None: - if isinstance(result, dict): - result = self._enrich_activity_detail_payload(source_name=source_name, raw_id=raw_id, payload=result) - self._set_cached(cache_key, result) - if isinstance(result, dict): - self._activity_index[composite_id] = result - return result - - async def get_training_load_history(self, *, days: int) -> list[dict]: - safe_days = max(1, min(days, 180)) - cache_key = f"training_load_history:{safe_days}" - cached = self._get_cached(cache_key) - if cached is not None: - return cached # type: ignore[return-value] - - result = await self._measure( - "get_training_load_history", - lambda: self._load_training_load_history_merged(days=safe_days), - ) - self._set_cached(cache_key, result) - return result - - async def get_recovery_readiness_signals(self, *, days: int) -> dict: - safe_days = max(1, min(days, 30)) - cache_key = f"recovery_readiness_signals:{safe_days}" - cached = self._get_cached(cache_key) - if cached is not None: - return cached # type: ignore[return-value] - - async def _load_one(source_name: str, provider: OngoingTrainingProvider) -> dict: - payload = await provider.get_recovery_readiness_signals(safe_days) - return payload if isinstance(payload, dict) else {} - - async def _load(): - if not self._providers: - return { - "window_days": safe_days, - "as_of_utc": datetime.now(UTC).isoformat(), - "sources": {}, - "provider_status": self._provider_observability()["training_providers"], - "evidence_profile": self._current_evidence_profile(), - } - provider_entries = list(self._providers.items()) - tasks = [ - _load_one(source_name, provider) for source_name, provider in self._providers.items() - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - sources: dict[str, dict] = {} - for (source_name, _provider), payload in zip(provider_entries, results, strict=True): - if isinstance(payload, BaseException): - degraded_error = self._degraded_provider_error(payload) - if degraded_error is not None: - self._mark_provider_unavailable(source_name=source_name, exc=degraded_error) - continue - raise payload - sources[source_name] = payload - return { - "window_days": safe_days, - "as_of_utc": datetime.now(UTC).isoformat(), - "sources": sources, - "provider_status": self._provider_observability()["training_providers"], - "evidence_profile": self._current_evidence_profile(), - } - - result = await self._measure("get_recovery_readiness_signals", _load) - self._set_cached(cache_key, result) - return result - - async def _load_snapshot_inputs(self, *, today: date) -> tuple[list[dict], list[dict], dict, dict, list[dict]]: - return await asyncio.gather( - self.get_recent_activities(date_from=today - timedelta(days=6), date_to=today, detail_level="summary"), - self.get_training_load_history(days=28), - self.get_recovery_readiness_signals(days=7), - self.get_current_weekly_plan(), - self.get_upcoming_competitions(), - ) - - @staticmethod - def _derive_load_trend(load_28: list[dict]) -> tuple[str, float | None, float | None]: - loads = [value for entry in load_28 if isinstance(entry, dict) and (value := _extract_load(entry)) is not None] - recent_mean = mean(loads[-7:]) if len(loads) >= 7 else (mean(loads) if loads else None) - prior_window = loads[-28:-7] if len(loads) > 7 else [] - prior_mean = mean(prior_window) if prior_window else None - if recent_mean is None or prior_mean is None: - return "insufficient_data", recent_mean, prior_mean - if recent_mean > prior_mean * 1.05: - return "rising", recent_mean, prior_mean - if recent_mean < prior_mean * 0.95: - return "falling", recent_mean, prior_mean - return "stable", recent_mean, prior_mean - - @staticmethod - def _find_next_planned_session(weekly_plan: dict, *, today: date) -> dict | None: - weeks = weekly_plan.get("weeks", []) - if not weeks or not isinstance(weeks[0], dict): - return None - - week_days = weeks[0].get("days", []) - if not isinstance(week_days, list): - return None - - for day_row in week_days: - if not isinstance(day_row, dict): - continue - blocks = day_row.get("blocks", []) - if not isinstance(blocks, list) or not blocks: - continue - day_raw = day_row.get("date") - if not isinstance(day_raw, str) or not day_raw: - continue - try: - day_date = date.fromisoformat(day_raw[:10]) - except ValueError: - continue - if day_date >= today: - return { - "date": day_date.isoformat(), - "day_id": day_row.get("day_id"), - "block_count": len(blocks), - } - return None - - async def get_training_snapshot(self) -> dict: - today = datetime.now(UTC).date() - activities_7, load_28, recovery_7, weekly_plan, competitions = await self._load_snapshot_inputs(today=today) - sessions_by_source: dict[str, int] = {} - for activity in activities_7: - source = activity.get("source") - if isinstance(source, str) and source: - sessions_by_source[source] = sessions_by_source.get(source, 0) + 1 - - load_trends_by_source: dict[str, dict] = {} - for source_name in sorted(self._providers.keys()): - subset = [ - entry - for entry in load_28 - if isinstance(entry, dict) and entry.get("source") == source_name - ] - trend, recent_mean, prior_mean = self._derive_load_trend(subset) - load_trends_by_source[source_name] = { - "load_trend": trend, - "recent_mean_7d": recent_mean, - "prior_mean_21d": prior_mean, - } - - next_planned_session = self._find_next_planned_session(weekly_plan, today=today) - - payload: dict[str, object] = { - "as_of_date": today.isoformat(), - "sessions_7d": len(activities_7), - "sessions_7d_by_source": sessions_by_source, - "load_trends_by_source": load_trends_by_source, - "recovery_sources": recovery_7.get("sources") if isinstance(recovery_7, dict) else {}, - "provider_status": self._provider_observability()["training_providers"], - "evidence_profile": self._current_evidence_profile(), - "competition_proximity_days": nearest_competition_days(competitions, today), - "next_planned_session": next_planned_session, - } - # Backwards-friendly keys for single-provider environments. - if len(load_trends_by_source) == 1: - only = next(iter(load_trends_by_source.values())) - payload["load_trend"] = only.get("load_trend") - payload["recent_mean_7d"] = only.get("recent_mean_7d") - payload["prior_mean_21d"] = only.get("prior_mean_21d") + payload = await self._measure(cache_key, _load) + self._request_cache[cache_key] = payload return payload - async def get_expert_analysis_summary(self) -> dict: - active_analysis = await self._load_active_analysis() - if active_analysis is None: - return { - "run_date": None, - "age_days": None, - "staleness": "unknown", - "domains": {}, - } - - updated_at = active_analysis.updated_at.astimezone(UTC) - age_days = (datetime.now(UTC) - updated_at).days - expert_context = active_analysis.expert_context or {} - - def _domain_summary(domain_key: str) -> dict: - domain_payload = expert_context.get(domain_key, {}) - if not isinstance(domain_payload, dict): - return {"key_finding": None, "confidence": "unknown"} - output = domain_payload.get("output") - if isinstance(output, dict): - synthesis = output.get("for_synthesis") - if isinstance(synthesis, dict): - signals = synthesis.get("signals") - if isinstance(signals, list) and signals: - return {"key_finding": str(signals[0]), "confidence": "medium"} - return {"key_finding": None, "confidence": "unknown"} - - return { - "run_date": updated_at.date().isoformat(), - "age_days": age_days, - "staleness": _staleness_label(age_days), - "analysis_version": active_analysis.version, - "domains": { - "metrics": _domain_summary("metrics_outputs"), - "activity": _domain_summary("activity_outputs"), - "physiology": _domain_summary("physiology_outputs"), - }, - } - - async def get_expert_output( - self, - *, - domain: Literal["metrics", "activity", "physiology"], - target: Literal["for_synthesis", "for_season_planner", "for_weekly_planner"], - ) -> dict: - active_analysis = await self._load_active_analysis() - if active_analysis is None: - return {"status": "missing", "domain": domain, "target": target, "payload": None} - - domain_key = { - "metrics": "metrics_outputs", - "activity": "activity_outputs", - "physiology": "physiology_outputs", - }[domain] - - expert_context = active_analysis.expert_context or {} - domain_payload = expert_context.get(domain_key, {}) - updated_at = active_analysis.updated_at.astimezone(UTC) - age_days = (datetime.now(UTC) - updated_at).days - - if not isinstance(domain_payload, dict): - return { - "status": "missing", - "domain": domain, - "target": target, - "age_days": age_days, - "created_at": updated_at.isoformat(), - "payload": None, - } - - output = domain_payload.get("output") - if isinstance(output, list): - return { - "status": "needs_clarification", - "domain": domain, - "target": target, - "age_days": age_days, - "created_at": updated_at.isoformat(), - "questions": output, - } - - if not isinstance(output, dict): - return { - "status": "missing", - "domain": domain, - "target": target, - "age_days": age_days, - "created_at": updated_at.isoformat(), - "payload": None, - } - - return { - "status": "ok", - "domain": domain, - "target": target, - "age_days": age_days, - "created_at": updated_at.isoformat(), - "payload": output.get(target), - } - def get_observability_snapshot(self) -> dict: return { "tool_usage": self._tool_usage, - "cache_keys": sorted(self._request_cache.keys()), - "provider": self._provider_observability(), - "evidence_profile": self._current_evidence_profile(), + "cache_keys": sorted(self._request_cache), + "source_of_truth": "local_athlete_owned", } - def _tool_get_training_snapshot(self): - @tool("get_training_snapshot") - async def get_training_snapshot_tool() -> dict: - """Get a compact overview: 7-day session count, 28-day load trend (rising/falling/stable), recovery status, next race proximity, and next planned session. Start here before drilling into details.""" - return await self.get_training_snapshot() - - return get_training_snapshot_tool - - def _tool_get_expert_analysis_summary(self): - @tool("get_expert_analysis_summary") - async def get_expert_analysis_summary_tool() -> dict: - """Get a compact summary of the latest full analysis run with staleness metadata.""" - return await self.get_expert_analysis_summary() - - return get_expert_analysis_summary_tool - - def _tool_get_expert_output(self): - @tool("get_expert_output") - async def get_expert_output_tool( - domain: Literal["metrics", "activity", "physiology"], - target: Literal["for_synthesis", "for_season_planner", "for_weekly_planner"], - ) -> dict: - """Get deep expert analysis for a specific domain and target. domain: 'metrics' (pace/HR/power zones), 'activity' (training pattern analysis), 'physiology' (recovery/adaptation). target: 'for_synthesis' (narrative summary), 'for_season_planner' (periodization data), 'for_weekly_planner' (session-level detail). Returns {status, domain, target, age_days, created_at, payload}.""" - return await self.get_expert_output(domain=domain, target=target) - - return get_expert_output_tool - - def _tool_get_current_analysis(self): - @tool("get_current_analysis") - async def get_current_analysis_tool() -> dict: - """Get rendered dashboard analysis: exact athlete-visible KPIs/sections + {version, updated_at}.""" - return await self.get_current_analysis() - - return get_current_analysis_tool - def _tool_get_current_weekly_plan(self): @tool("get_current_weekly_plan") async def get_current_weekly_plan_tool() -> dict: - """Get full active weekly plan JSON + {version, updated_at}.""" + """Get the complete active 28-day execution plan, including its version and update time.""" return await self.get_current_weekly_plan() return get_current_weekly_plan_tool @@ -977,7 +196,7 @@ async def get_current_weekly_plan_tool() -> dict: def _tool_get_current_season_plan(self): @tool("get_current_season_plan") async def get_current_season_plan_tool() -> dict: - """Get full active season plan JSON + {version, updated_at}.""" + """Get the complete active season strategy, including its version and update time.""" return await self.get_current_season_plan() return get_current_season_plan_tool @@ -985,7 +204,7 @@ async def get_current_season_plan_tool() -> dict: def _tool_get_upcoming_competitions(self): @tool("get_upcoming_competitions") async def get_upcoming_competitions_tool() -> list[dict]: - """Get upcoming competitions and priorities.""" + """Get the athlete's declared competitions, goals, priorities, and notes.""" return await self.get_upcoming_competitions() return get_upcoming_competitions_tool @@ -993,103 +212,31 @@ async def get_upcoming_competitions_tool() -> list[dict]: def _tool_get_athlete_profile(self): @tool("get_athlete_profile") async def get_athlete_profile_tool() -> dict: - """Get long-term athlete profile context (memory summary, athlete model, transient states, and memory freshness metadata).""" + """Get the athlete-owned profile, coaching memory, and current declared context.""" return await self.get_athlete_profile() return get_athlete_profile_tool - def _tool_get_recent_activities(self): - @tool("get_recent_activities") - async def get_recent_activities_tool( - date_from: str, - date_to: str, - sport_filters: list[str] | None = None, - detail_level: Literal["summary", "full"] = "summary", - ) -> list[dict]: - """Get activities in a date range from all connected providers (Strava and/or WHOOP). - - date_from/date_to must be YYYY-MM-DD (ISO 8601). detail_level 'summary' returns compact cards; - 'full' returns provider-native payloads. activity_id values are composite IDs: '{source}:{id}'. - """ - return await self.get_recent_activities( - date_from=date.fromisoformat(date_from), - date_to=date.fromisoformat(date_to), - sport_filters=sport_filters, - detail_level=detail_level, - ) - - return get_recent_activities_tool - - def _tool_get_activity_detail(self): - @tool("get_activity_detail") - async def get_activity_detail_tool(activity_id: str) -> dict | None: - """Get one full activity by composite activity_id (from get_recent_activities). + @classmethod + def registered_tool_names(cls) -> set[str]: + return set(cls._REGISTERED_TOOL_NAMES) - activity_id is '{source}:{id}' where source is 'strava' or 'whoop'. - Returns provider-native data for that activity. - """ - return await self.get_activity_detail(activity_id=activity_id) - - return get_activity_detail_tool - - def _tool_get_training_load_history(self): - @tool("get_training_load_history") - async def get_training_load_history_tool(days: int = 28) -> list[dict]: - """Get training load history for N days (clamped to 1..180) from all connected providers. - - Each entry includes {source, load_type, load_value} and preserves provider-native fields. - """ - return await self.get_training_load_history(days=days) - - return get_training_load_history_tool - - def _tool_get_recovery_readiness_signals(self): - @tool("get_recovery_readiness_signals") - async def get_recovery_readiness_signals_tool(days: int = 7) -> dict: - """Get recovery/readiness signals for N days (clamped to 1..30) from all connected providers.""" - return await self.get_recovery_readiness_signals(days=days) - - return get_recovery_readiness_signals_tool - - def create_langchain_tools(self) -> list: - return [ - self._tool_get_training_snapshot(), - self._tool_get_expert_analysis_summary(), - self._tool_get_expert_output(), - self._tool_get_current_analysis(), + def create_langchain_tools(self, *, allowed_tool_names: Collection[str] | None = None) -> list: + tools = [ self._tool_get_current_weekly_plan(), self._tool_get_current_season_plan(), self._tool_get_upcoming_competitions(), self._tool_get_athlete_profile(), - self._tool_get_recent_activities(), - self._tool_get_activity_detail(), - self._tool_get_training_load_history(), - self._tool_get_recovery_readiness_signals(), ] + if allowed_tool_names is None: + return tools + allowed = set(allowed_tool_names) + unknown = allowed - self._REGISTERED_TOOL_NAMES + if unknown: + raise ValueError(f"Unknown ongoing tool names: {sorted(unknown)!r}") + return [tool_instance for tool_instance in tools if tool_instance.name in allowed] @asynccontextmanager -async def build_ongoing_tool_registry( - db: AsyncSession, - *, - user_id, - require_training_provider: bool = True, -): - providers: dict[str, OngoingTrainingProvider] = {} - for name, builder in ( - ("strava", build_ongoing_strava_provider), - ("whoop", build_ongoing_whoop_provider), - ): - try: - providers[name] = await builder(db, user_id=user_id) - except HTTPException: - continue - - if require_training_provider and not providers: - raise HTTPException(status_code=404, detail="No training data source connected") - - registry = OngoingToolRegistry(db=db, user_id=user_id, providers=providers) - try: - yield registry - finally: - pass +async def build_ongoing_tool_registry(db: AsyncSession, *, user_id): + yield OngoingToolRegistry(db=db, user_id=user_id) diff --git a/api/services/plan_generation_lock.py b/api/services/plan_generation_lock.py new file mode 100644 index 0000000..2477673 --- /dev/null +++ b/api/services/plan_generation_lock.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import hashlib +import uuid + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +def owner_plan_generation_lock_key(user_id: uuid.UUID) -> int: + digest = hashlib.blake2b(f"owner:{user_id}:plan-generation".encode(), digest_size=8).digest() + return int.from_bytes(digest, byteorder="big", signed=True) + + +async def lock_owner_plan_generation(db: AsyncSession, *, user_id: uuid.UUID) -> None: + """Serialize availability checks and job creation for one local owner.""" + await db.execute( + text("SELECT pg_advisory_xact_lock(:lock_key)"), + {"lock_key": owner_plan_generation_lock_key(user_id)}, + ) diff --git a/api/services/recap.py b/api/services/recap.py index 9ee7e31..4882758 100644 --- a/api/services/recap.py +++ b/api/services/recap.py @@ -1,6 +1,5 @@ from __future__ import annotations -import html import logging import re import uuid @@ -30,58 +29,22 @@ EVENT_RECAP_NARRATIVE, append_coach_events, ) -from api.services.coach_patch_ops import apply_ops, sanitize_ops +from api.services.coach_patch_ops import apply_ops, parse_weekly_plan, prepare_ops_for_plan from api.services.coach_quota import get_coach_weekly_quota from api.services.coach_thread_titles import derive_weekly_recap_thread_title -from api.services.connected_coaching import assert_connected_coaching_available from api.services.full_run_policy import WeeklyRecapAvailability, evaluate_weekly_recap_availability -from api.services.html_sanitizer import sanitize_html -from api.services.integration_status import load_integrations_status from api.services.local_usage import get_local_usage_context from api.services.ongoing_tools import build_ongoing_tool_registry -from services.ai.langgraph.schemas.ui_blocks import UiHtmlBlock, UiWeeklyPlan +from services.ai.head_coach.artifacts import ExecutionPlanArtifactV3 +from services.ai.langgraph.schemas.ui_blocks import UiWeeklyPlan from services.ai.recap import WeeklyRecapNarrative, generate_weekly_recap_narrative logger = logging.getLogger(__name__) StatusEmitter = Callable[[dict[str, object]], object] -_HTML_TAG_PATTERN = re.compile(r"<[^>]*>") _SENTENCE_BREAK_PATTERN = re.compile(r"[.!?](?=\s|$)") _CLAUSE_BREAK_PATTERN = re.compile(r"[,;:](?=\s|$)") -def _sanitize_recap_blocks(blocks: list[UiHtmlBlock]) -> list[UiHtmlBlock]: - sanitized: list[UiHtmlBlock] = [] - seen: set[str] = set() - for block in blocks: - key = block.key - if key in seen: - suffix = 1 - while f"{key}-{suffix}" in seen: - suffix += 1 - key = f"{key}-{suffix}" - seen.add(key) - sanitized.append( - block.model_copy( - update={ - "key": key, - "content_html": sanitize_html(block.content_html), - "tone": block.tone if block.variant == "callout" else None, - } - ) - ) - return sanitized - - -def _sanitize_recap_payload(payload: WeeklyRecapNarrative) -> WeeklyRecapNarrative: - return payload.model_copy( - update={ - "this_week_blocks": _sanitize_recap_blocks(payload.this_week_blocks), - "looking_ahead_blocks": _sanitize_recap_blocks(payload.looking_ahead_blocks), - "optional_proposal_ops": sanitize_ops(payload.optional_proposal_ops), - } - ) - - def _truncate_preview_text(preview: str, *, max_chars: int) -> str: if len(preview) <= max_chars: return preview @@ -113,14 +76,24 @@ def _truncate_preview_text(preview: str, *, max_chars: int) -> str: return f"{candidate.rstrip(' ,;:')}..." -def _extract_html_text(content_html: str) -> str: - normalized = html.unescape(_HTML_TAG_PATTERN.sub(" ", content_html)).replace("\n", " ") - return re.sub(r"\s+", " ", normalized).strip() - - -def _build_html_preview(content_html: str, *, max_chars: int = 180) -> str: - preview = _extract_html_text(content_html) - return _truncate_preview_text(preview, max_chars=max_chars) +def _semantic_block_text(block: dict) -> str: + for key in ("markdown", "objective_markdown", "summary_markdown"): + value = block.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + items = block.get("items") + if isinstance(items, list): + return "; ".join( + str(item.get("label") or "").strip() for item in items if isinstance(item, dict) + ).strip("; ") + intervals = block.get("intervals") + if isinstance(intervals, list): + return "; ".join( + " ".join(str(row.get(key) or "").strip() for key in ("label", "duration", "prescription")).strip() + for row in intervals + if isinstance(row, dict) + ).strip("; ") + return "" def extract_recap_summary_preview(recap_payload: object) -> str | None: @@ -137,12 +110,12 @@ def extract_recap_summary_preview(recap_payload: object) -> str | None: for raw_block in section_blocks: if not isinstance(raw_block, dict): continue - title = str(raw_block.get("title") or "").strip() + title = str(raw_block.get("title") or raw_block.get("label") or "").strip() if title: return title - content_html = raw_block.get("content_html") - if isinstance(content_html, str) and content_html.strip(): - return _build_html_preview(content_html, max_chars=220) + content = _semantic_block_text(raw_block) + if content: + return _truncate_preview_text(content, max_chars=220) return None @@ -166,12 +139,12 @@ def extract_recap_action_preview( for raw_block in section_blocks: if not isinstance(raw_block, dict): continue - title = str(raw_block.get("title") or "").strip() + title = str(raw_block.get("title") or raw_block.get("label") or "").strip() if title and title != normalized_exclude: return title - content_html = raw_block.get("content_html") - if isinstance(content_html, str) and content_html.strip(): - preview = _build_html_preview(content_html, max_chars=220) + content = _semantic_block_text(raw_block) + if content: + preview = _truncate_preview_text(content, max_chars=220) if preview and preview != normalized_exclude: return preview return None @@ -314,7 +287,7 @@ async def _prepare_pending_recap_run( async def _get_active_weekly_plan_for_recap( db: AsyncSession, *, user_id: uuid.UUID, run: WeeklyRecapRun -) -> tuple[ActiveWeeklyPlan, UiWeeklyPlan]: +) -> tuple[ActiveWeeklyPlan, UiWeeklyPlan | ExecutionPlanArtifactV3]: weekly_row = await db.execute(select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == user_id)) active_weekly = weekly_row.scalar_one_or_none() if not active_weekly: @@ -323,7 +296,7 @@ async def _get_active_weekly_plan_for_recap( db.add(run) await db.flush() raise HTTPException(status_code=404, detail="No active weekly plan found") - return active_weekly, UiWeeklyPlan.model_validate(active_weekly.plan_data) + return active_weekly, parse_weekly_plan(active_weekly.plan_data) async def _generate_recap_narrative( @@ -409,7 +382,7 @@ async def _generate_recap_narrative( await db.flush() raise return ( - _sanitize_recap_payload(narrative), + narrative, observability, ai_trace.trace_metadata(), capture_langsmith_run_costs(ai_trace.trace_metadata()), @@ -422,19 +395,20 @@ async def _create_optional_recap_proposal( user_id: uuid.UUID, thread_id: uuid.UUID, active_weekly: ActiveWeeklyPlan, - current_plan: UiWeeklyPlan, + current_plan: UiWeeklyPlan | ExecutionPlanArtifactV3, narrative: WeeklyRecapNarrative, ) -> tuple[uuid.UUID | None, dict | None, bool]: if not narrative.optional_proposal_ops: return None, None, False - preview_plan, changed = apply_ops(current_plan, narrative.optional_proposal_ops) + prepared_ops = prepare_ops_for_plan(current_plan, narrative.optional_proposal_ops) + preview_plan, changed = apply_ops(current_plan, prepared_ops) proposal_row = CoachProposal( user_id=user_id, thread_id=thread_id, weekly_plan_version=active_weekly.version, assistant_message="Weekly recap proposes plan adaptations based on this week's execution.", - ops={"ops": [op.model_dump(mode="json") for op in narrative.optional_proposal_ops]}, + ops={"ops": [op.model_dump(mode="json") for op in prepared_ops]}, origin="weekly_recap", status="pending", ) @@ -536,7 +510,6 @@ async def execute_recap_turn( ) -> tuple[dict, list[CoachEvent]]: recap_usage_context = await get_local_usage_context(db, user_id=user_id) availability = await evaluate_weekly_recap_availability(db, user_id=user_id) - integrations_status = await load_integrations_status(db, user_id=user_id) if availability.existing_run_id is not None: existing_row = await db.execute( @@ -553,11 +526,8 @@ async def execute_recap_turn( if not availability.allowed: raise HTTPException(status_code=409, detail=_recap_unavailable_detail(availability)) - assert_connected_coaching_available( - feature_enabled=bool(recap_usage_context.effective_plan.weekly_recap_included), - integrations_status=integrations_status, - locked_message="Weekly recap is not available on this plan.", - ) + if not recap_usage_context.effective_plan.weekly_recap_included: + raise HTTPException(status_code=400, detail="Weekly recap is not available on this plan.") anchor = availability.current_anchor_utc if anchor is None or availability.window_start is None or availability.window_end is None: @@ -643,7 +613,10 @@ async def execute_recap_turn( "looking_ahead_blocks": [block.model_dump(mode="json") for block in narrative.looking_ahead_blocks], }, "base_weekly_plan": current_plan.model_dump(mode="json"), - "ops": [op.model_dump(mode="json") for op in narrative.optional_proposal_ops], + "ops": [ + op.model_dump(mode="json") + for op in prepare_ops_for_plan(current_plan, narrative.optional_proposal_ops) + ], "preview_weekly_plan": preview_weekly_payload, "changed": changed, } diff --git a/api/services/status_messages.py b/api/services/status_messages.py index 4a8e9e9..247a703 100644 --- a/api/services/status_messages.py +++ b/api/services/status_messages.py @@ -1,65 +1,41 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Any, Literal, cast ProgressStepStatus = Literal["pending", "active", "completed"] TOOL_STATUS_MESSAGES: dict[str, str] = { - "get_training_snapshot": "Checking your current training snapshot...", - "get_recent_activities": "Reviewing your recent activities...", - "get_activity_detail": "Checking full details of an activity...", - "get_training_load_history": "Reviewing your training load history...", - "get_recovery_readiness_signals": "Checking recovery and readiness signals...", - "get_expert_analysis_summary": "Reviewing your latest analysis results...", - "get_expert_output": "Pulling expert insights...", - "get_current_analysis": "Loading your current dashboard analysis...", "get_current_weekly_plan": "Loading your current weekly plan...", "get_current_season_plan": "Loading your season plan...", "get_upcoming_competitions": "Checking your upcoming races...", + "get_athlete_profile": "Loading your athlete profile...", } NODE_STATUS_MESSAGES: dict[str, str] = { - "metrics_summarizer": "Summarizing training metrics...", - "physiology_summarizer": "Summarizing physiology data...", - "activity_summarizer": "Summarizing recent activities...", - "training_data_compaction": "Compacting source context...", - "metrics_expert": "Expert analyzing training metrics...", - "physiology_expert": "Expert analyzing physiology signals...", - "activity_expert": "Expert analyzing activity patterns...", - "master_orchestrator": "Coordinating analysis results...", - "synthesis": "Synthesizing findings...", - "plot_resolution": "Preparing visualizations...", - "analysis_formatter": "Formatting analysis report...", - "season_planner": "Planning your season...", - "data_integration": "Integrating data for weekly planning...", - "weekly_planner": "Building your weekly plan...", - "season_formatter": "Formatting season plan...", - "weekly_formatter": "Formatting weekly plan...", - "finalize": "Finalizing results...", + "head_coach_understanding_context": "Understanding your goals and constraints...", + "head_coach_designing_strategy": "Designing your season strategy...", + "head_coach_reviewing_constraints": "Reviewing the plan against your constraints...", + "head_coach_awaiting_input": "Waiting for one important answer from you...", + "head_coach_building_execution_block": "Building your next 28 days...", + "head_coach_saving_plan": "Saving your coaching plan...", } -ANALYSIS_PROGRESS_NODE_ORDER: list[str] = [ - "metrics_summarizer", - "physiology_summarizer", - "activity_summarizer", - "training_data_compaction", - "metrics_expert", - "physiology_expert", - "activity_expert", - "master_orchestrator", - "synthesis", - "plot_resolution", - "analysis_formatter", - "season_planner", - "data_integration", - "weekly_planner", - "season_formatter", - "weekly_formatter", - "finalize", +HEAD_COACH_PROGRESS_NODE_ORDER: list[str] = [ + "head_coach_understanding_context", + "head_coach_designing_strategy", + "head_coach_reviewing_constraints", + "head_coach_awaiting_input", + "head_coach_building_execution_block", + "head_coach_saving_plan", ] +def _progress_node_order(progress_steps: list[dict[str, Any]] | None) -> list[str]: + del progress_steps + return HEAD_COACH_PROGRESS_NODE_ORDER + + def _try_parse_iso_date(raw_value: object) -> datetime | None: if not isinstance(raw_value, str): return None @@ -91,10 +67,6 @@ def _render_date_range_label(args: dict[str, Any] | None) -> str | None: def tool_status_message(tool_name: str, args: dict[str, Any] | None = None) -> str: - if tool_name == "get_recent_activities": - date_range_label = _render_date_range_label(args) - if date_range_label: - return f"Reviewing your activities from {date_range_label}..." return TOOL_STATUS_MESSAGES.get(tool_name, "Reviewing your training context...") @@ -105,7 +77,8 @@ def node_status_message(node_name: str) -> str: def normalize_analysis_progress_steps(progress_steps: list[dict[str, Any]] | None) -> list[dict[str, Any]]: by_node: dict[str, dict[str, Any]] = {} extra_steps: list[dict[str, Any]] = [] - known_nodes = set(ANALYSIS_PROGRESS_NODE_ORDER) + node_order = _progress_node_order(progress_steps) + known_nodes = set(node_order) for raw_step in progress_steps or []: node_name = str(raw_step.get("node", "")).strip() @@ -146,13 +119,15 @@ def normalize_analysis_progress_steps(progress_steps: list[dict[str, Any]] | Non "status": "pending", }, ) - for node_name in ANALYSIS_PROGRESS_NODE_ORDER + for node_name in node_order ] return ordered_steps + extra_steps -def initial_analysis_progress_steps() -> list[dict[str, Any]]: - return normalize_analysis_progress_steps(None) +def initial_head_coach_progress_steps() -> list[dict[str, Any]]: + return normalize_analysis_progress_steps( + [{"node": node, "status": "pending"} for node in HEAD_COACH_PROGRESS_NODE_ORDER] + ) def mark_analysis_progress_step_started( @@ -184,36 +159,6 @@ def mark_analysis_progress_step_started( return current_steps, str(target_step["label"]) -def mark_analysis_progress_step_completed( - progress_steps: list[dict[str, Any]] | None, - *, - node_name: str, - timestamp: datetime | None = None, -) -> list[dict[str, Any]]: - current_steps = normalize_analysis_progress_steps(progress_steps) - now_iso = (timestamp or datetime.now(UTC)).astimezone(UTC).isoformat() - - target_step: dict[str, Any] | None = None - for step in current_steps: - if step["node"] == node_name: - target_step = step - break - - if target_step is None: - target_step = { - "node": node_name, - "label": node_status_message(node_name), - "status": "pending", - } - current_steps.append(target_step) - - if "started_at" not in target_step: - target_step["started_at"] = now_iso - target_step["status"] = "completed" - target_step["completed_at"] = now_iso - return current_steps - - def complete_active_analysis_progress_steps( progress_steps: list[dict[str, Any]] | None, *, @@ -228,38 +173,6 @@ def complete_active_analysis_progress_steps( return current_steps -def record_analysis_step_timing( - progress_steps: list[dict[str, Any]] | None, - *, - node_name: str, - duration_seconds: float, - timestamp: datetime | None = None, -) -> list[dict[str, Any]]: - current_steps = normalize_analysis_progress_steps(progress_steps) - completed_at = (timestamp or datetime.now(UTC)).astimezone(UTC) - safe_duration_seconds = max(float(duration_seconds), 0.0) - started_at = completed_at - timedelta(seconds=safe_duration_seconds) - - target_step: dict[str, Any] | None = None - for step in current_steps: - if step["node"] == node_name: - target_step = step - break - - if target_step is None: - target_step = { - "node": node_name, - "label": node_status_message(node_name), - "status": "pending", - } - current_steps.append(target_step) - - target_step["actual_started_at"] = started_at.isoformat() - target_step["actual_completed_at"] = completed_at.isoformat() - target_step["duration_seconds"] = round(safe_duration_seconds, 3) - return current_steps - - def current_analysis_step(progress_steps: list[dict[str, Any]] | None) -> str | None: active_labels = [ str(step["label"]) diff --git a/api/services/strava_tokens.py b/api/services/strava_tokens.py deleted file mode 100644 index 2dc20a8..0000000 --- a/api/services/strava_tokens.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from datetime import UTC, datetime, timedelta - -import httpx -from fastapi import HTTPException -from sqlalchemy import delete, select - -from api.config import get_settings -from api.models.credentials import StravaCredentials -from api.services.crypto import get_crypto_service -from api.services.integration_connections import mark_integration_disconnected -from services.strava.oauth import compute_expires_at -from services.strava.oauth import refresh_tokens as _refresh_tokens_sync - -logger = logging.getLogger(__name__) - - -async def ensure_valid_access_token(db, *, user_id) -> str: - settings = get_settings() - if not settings.strava_oauth_client_id or not settings.strava_oauth_client_secret: - raise HTTPException(status_code=500, detail="Strava OAuth is not configured (missing client_id/client_secret).") - - row = await db.execute( - select(StravaCredentials).where(StravaCredentials.user_id == user_id).with_for_update() - ) - creds = row.scalar_one_or_none() - if creds is None: - raise HTTPException(status_code=404, detail="Strava is not connected for this account.") - - crypto = get_crypto_service() - now = datetime.now(UTC) - if creds.expires_at is not None and creds.expires_at.astimezone(UTC) > (now + timedelta(seconds=30)): - return crypto.decrypt(creds.encrypted_access_token) - - if not creds.encrypted_refresh_token: - raise HTTPException(status_code=409, detail="Strava refresh token is missing. Please reconnect Strava.") - - refresh_token = crypto.decrypt(creds.encrypted_refresh_token) - try: - payload = await asyncio.to_thread( - _refresh_tokens_sync, - refresh_token=refresh_token, - client_id=settings.strava_oauth_client_id, - client_secret=settings.strava_oauth_client_secret, - ) - except httpx.HTTPStatusError as exc: - status_code = exc.response.status_code if exc.response is not None else None - if status_code in {400, 401}: - logger.info("Strava token refresh rejected (status=%s); deleting stored credentials", status_code) - await db.execute(delete(StravaCredentials).where(StravaCredentials.user_id == user_id)) - await mark_integration_disconnected( - db, - user_id=user_id, - provider="strava", - reason="token_refresh_rejected", - ) - await db.commit() - raise HTTPException(status_code=401, detail="Strava connection expired. Please reconnect Strava.") from exc - raise HTTPException(status_code=502, detail="Strava token refresh failed. Please try again.") from exc - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail="Strava token refresh failed. Please try again.") from exc - - access_token = payload.get("access_token") - if not isinstance(access_token, str) or not access_token.strip(): - raise HTTPException(status_code=502, detail="Strava token refresh returned an invalid access token.") - - new_refresh = payload.get("refresh_token") - expires_at = compute_expires_at( - now=now, - expires_at=payload.get("expires_at"), - expires_in=payload.get("expires_in"), - ) - scope = creds.scope or "" - - creds.encrypted_access_token = crypto.encrypt(access_token) - if isinstance(new_refresh, str) and new_refresh.strip(): - creds.encrypted_refresh_token = crypto.encrypt(new_refresh) - creds.expires_at = expires_at - creds.scope = str(scope or "") - db.add(creds) - - return access_token diff --git a/api/services/whoop_tokens.py b/api/services/whoop_tokens.py deleted file mode 100644 index 8dbaf5f..0000000 --- a/api/services/whoop_tokens.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from datetime import UTC, datetime, timedelta - -import httpx -from fastapi import HTTPException -from sqlalchemy import delete, select - -from api.config import get_settings -from api.models.credentials import WhoopCredentials -from api.services.crypto import get_crypto_service -from api.services.integration_connections import mark_integration_disconnected -from services.whoop.oauth import compute_expires_at -from services.whoop.oauth import refresh_tokens as _refresh_tokens_sync - -logger = logging.getLogger(__name__) - - -async def ensure_valid_access_token(db, *, user_id) -> str: - settings = get_settings() - if not settings.whoop_oauth_client_id or not settings.whoop_oauth_client_secret: - raise HTTPException(status_code=500, detail="WHOOP OAuth is not configured (missing client_id/client_secret).") - - # Lock to avoid concurrent refresh requests invalidating each other. - row = await db.execute( - select(WhoopCredentials).where(WhoopCredentials.user_id == user_id).with_for_update() - ) - creds = row.scalar_one_or_none() - if creds is None: - raise HTTPException(status_code=404, detail="WHOOP is not connected for this account.") - - crypto = get_crypto_service() - now = datetime.now(UTC) - if creds.expires_at is not None and creds.expires_at.astimezone(UTC) > (now + timedelta(seconds=30)): - return crypto.decrypt(creds.encrypted_access_token) - - if not creds.encrypted_refresh_token: - raise HTTPException(status_code=409, detail="WHOOP refresh token is missing. Please reconnect WHOOP.") - - refresh_token = crypto.decrypt(creds.encrypted_refresh_token) - try: - payload = await asyncio.to_thread( - _refresh_tokens_sync, - refresh_token=refresh_token, - client_id=settings.whoop_oauth_client_id, - client_secret=settings.whoop_oauth_client_secret, - ) - except httpx.HTTPStatusError as exc: - status_code = exc.response.status_code if exc.response is not None else None - # Invalid grant or unauthorized: force reconnect. - if status_code in {400, 401}: - logger.info("Whoop token refresh rejected (status=%s); deleting stored credentials", status_code) - await db.execute(delete(WhoopCredentials).where(WhoopCredentials.user_id == user_id)) - await mark_integration_disconnected( - db, - user_id=user_id, - provider="whoop", - reason="token_refresh_rejected", - ) - await db.commit() - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") from exc - raise HTTPException(status_code=502, detail="WHOOP token refresh failed. Please try again.") from exc - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail="WHOOP token refresh failed. Please try again.") from exc - - access_token = payload.get("access_token") - if not isinstance(access_token, str) or not access_token.strip(): - raise HTTPException(status_code=502, detail="WHOOP token refresh returned an invalid access token.") - - new_refresh = payload.get("refresh_token") - expires_at = compute_expires_at(now=now, expires_in=payload.get("expires_in")) - scope = payload.get("scope") or creds.scope or "" - - creds.encrypted_access_token = crypto.encrypt(access_token) - if isinstance(new_refresh, str) and new_refresh.strip(): - creds.encrypted_refresh_token = crypto.encrypt(new_refresh) - creds.expires_at = expires_at - creds.scope = str(scope or "") - db.add(creds) - - return access_token diff --git a/cli/seed_active_plans.py b/cli/seed_active_plans.py deleted file mode 100644 index f70340b..0000000 --- a/cli/seed_active_plans.py +++ /dev/null @@ -1,234 +0,0 @@ -import argparse -import json -import os -import types -import uuid -from pathlib import Path - -from dotenv import load_dotenv -from sqlalchemy import create_engine, select -from sqlalchemy.orm import Session - -from api.models.active_analysis import ActiveAnalysis -from api.models.active_season_plan import ActiveSeasonPlan -from api.models.active_weekly_plan import ActiveWeeklyPlan -from api.models.job import AnalysisJob, JobStatus -from api.models.user import User -from services.ai.langgraph.schemas.expert_outputs import ( - ActivityExpertOutputs, - MetricsExpertOutputs, - PhysiologyExpertOutputs, -) -from services.ai.langgraph.schemas.ui_blocks import UiAnalysis, UiSeasonPlan, UiWeeklyPlan - - -def _database_url() -> str: - load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env", override=False) - database_url = os.getenv("DATABASE_URL") - if not database_url: - raise RuntimeError("DATABASE_URL is not set") - return database_url.replace("+asyncpg", "") - - -def _dump_pydantic_or_dict(value): - if value is None: - return None - if hasattr(value, "model_dump"): - return value.model_dump(mode="json") - if isinstance(value, dict): - return value - if isinstance(value, types.SimpleNamespace): - return value.__dict__ - return value - - -def _jsonable(value): - return json.loads(json.dumps(value, default=str)) - - -def _load_json(path: str) -> dict: - with open(path, encoding="utf-8") as f: - return json.load(f) - - -def _load_text(path: str) -> str: - with open(path, encoding="utf-8") as f: - return f.read() - - -def _upsert_active_plans( - db: Session, - *, - user_id: uuid.UUID, - job_id: uuid.UUID, - analysis: UiAnalysis, - season: UiSeasonPlan, - weekly: UiWeeklyPlan, - expert_context: dict, -) -> None: - active_weekly = db.execute(select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == user_id)).scalar_one_or_none() - if active_weekly: - active_weekly.plan_data = _jsonable(weekly.model_dump(mode="json")) - active_weekly.version += 1 - active_weekly.source_job_id = job_id - else: - db.add( - ActiveWeeklyPlan( - user_id=user_id, - version=1, - plan_data=_jsonable(weekly.model_dump(mode="json")), - source_job_id=job_id, - ) - ) - - active_season = db.execute(select(ActiveSeasonPlan).where(ActiveSeasonPlan.user_id == user_id)).scalar_one_or_none() - if active_season: - active_season.plan_data = _jsonable(season.model_dump(mode="json")) - active_season.version += 1 - active_season.source_job_id = job_id - else: - db.add( - ActiveSeasonPlan( - user_id=user_id, - version=1, - plan_data=_jsonable(season.model_dump(mode="json")), - source_job_id=job_id, - ) - ) - - active_analysis = db.execute(select(ActiveAnalysis).where(ActiveAnalysis.user_id == user_id)).scalar_one_or_none() - if active_analysis: - active_analysis.analysis_data = _jsonable(analysis.model_dump(mode="json")) - active_analysis.expert_context = expert_context - active_analysis.version += 1 - active_analysis.source_job_id = job_id - else: - db.add( - ActiveAnalysis( - user_id=user_id, - version=1, - analysis_data=_jsonable(analysis.model_dump(mode="json")), - expert_context=expert_context, - source_job_id=job_id, - ) - ) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Seed active plans for a local owner from exported analysis artifacts (dev-only)." - ) - parser.add_argument( - "--owner-external-id", - dest="owner_external_id", - required=True, - help="Stable local owner identifier.", - ) - parser.add_argument( - "--data-dir", - default="data", - help="Directory containing analysis_blocks.json, weekly_plan_blocks.json, season_plan_blocks.json, and *_expert.json", - ) - parser.add_argument("--athlete-name", default="Athlete") - parser.add_argument( - "--allow-missing-markdown", - action="store_true", - help="Do not fail if season_plan.md / weekly_plan.md are missing.", - ) - args = parser.parse_args() - - data_dir = args.data_dir - weekly_path = os.path.join(data_dir, "weekly_plan_blocks.json") - season_path = os.path.join(data_dir, "season_plan_blocks.json") - analysis_path = os.path.join(data_dir, "analysis_blocks.json") - metrics_expert_path = os.path.join(data_dir, "metrics_expert.json") - activity_expert_path = os.path.join(data_dir, "activity_expert.json") - physiology_expert_path = os.path.join(data_dir, "physiology_expert.json") - season_plan_md_path = os.path.join(data_dir, "season_plan.md") - weekly_plan_md_path = os.path.join(data_dir, "weekly_plan.md") - - required_paths = [weekly_path, season_path, analysis_path] - for path in required_paths: - if not os.path.exists(path): - raise RuntimeError(f"File not found: {path}") - - engine = create_engine(_database_url()) - with Session(engine) as db: - user = db.execute(select(User).where(User.local_owner_key == args.owner_external_id)).scalar_one_or_none() - if not user: - user = User(local_owner_key=args.owner_external_id, email=f"{args.owner_external_id}@local.paced") - db.add(user) - db.flush() - - seed_result: dict = { - "weekly_plan_blocks": _load_json(weekly_path), - "season_plan_blocks": _load_json(season_path), - "season_plan": _load_text(season_plan_md_path) if os.path.exists(season_plan_md_path) else None, - "weekly_plan": _load_text(weekly_plan_md_path) if os.path.exists(weekly_plan_md_path) else None, - "execution_id": "seed", - } - if not args.allow_missing_markdown and not seed_result.get("season_plan"): - raise RuntimeError( - f"File not found: {season_plan_md_path} (required for coach chat)." - ) - - job = AnalysisJob( - user_id=user.id, - status=JobStatus.COMPLETED.value, - config={}, - result=_jsonable(seed_result), - ) - db.add(job) - db.flush() - - weekly = UiWeeklyPlan.model_validate(_load_json(weekly_path)) - season = UiSeasonPlan.model_validate(_load_json(season_path)) - analysis = UiAnalysis.model_validate(_load_json(analysis_path)) - - # Allow overriding athlete_name for nicer UI display. - if args.athlete_name: - weekly.athlete_name = args.athlete_name - season.athlete_name = args.athlete_name - analysis.athlete_name = args.athlete_name - - metrics_outputs = ( - MetricsExpertOutputs.model_validate(_load_json(metrics_expert_path)) - if os.path.exists(metrics_expert_path) - else None - ) - activity_outputs = ( - ActivityExpertOutputs.model_validate(_load_json(activity_expert_path)) - if os.path.exists(activity_expert_path) - else None - ) - physiology_outputs = ( - PhysiologyExpertOutputs.model_validate(_load_json(physiology_expert_path)) - if os.path.exists(physiology_expert_path) - else None - ) - - expert_context = { - "metrics_outputs": _dump_pydantic_or_dict(metrics_outputs), - "activity_outputs": _dump_pydantic_or_dict(activity_outputs), - "physiology_outputs": _dump_pydantic_or_dict(physiology_outputs), - } - - _upsert_active_plans( - db, - user_id=user.id, - job_id=job.id, - analysis=analysis, - season=season, - weekly=weekly, - expert_context=expert_context, - ) - - db.commit() - - print(f"Seeded active plans for owner_external_id={args.owner_external_id} user_id={user.id} source_job_id={job.id}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/config/version_manifest.yaml b/config/version_manifest.yaml index 68409bb..1480875 100644 --- a/config/version_manifest.yaml +++ b/config/version_manifest.yaml @@ -5,13 +5,17 @@ release: components: ui_schema: - version: "1.0.0" - current_schema_version: 1 + version: "3.0.0" + current_schema_version: 3 db_schema: - version: "1.0.0" - alembic_head: "001_initial_local_first" + version: "1.1.0" + alembic_head: "002_head_coach_checkpoints" compatibility: ui_schema: - supported_versions: [1] + supported_versions: [1, 3] + supported_versions_by_kind: + analysis: [1] + season: [1, 3] + weekly: [1, 3] default_version: 1 diff --git a/core/config.py b/core/config.py index e26b675..059648e 100644 --- a/core/config.py +++ b/core/config.py @@ -18,12 +18,10 @@ class AIMode(Enum): COST_EFFECTIVE = "cost_effective" DEVELOPMENT = "development" PRO = "pro" - ANTHROPIC = "anthropic" @dataclass class Config: - anthropic_api_key: str | None = None openai_api_key: str | None = None deepseek_api_key: str | None = None openrouter_api_key: str | None = None @@ -32,7 +30,6 @@ class Config: @classmethod def from_env(cls) -> "Config": - anthropic_api_key = os.getenv("ANTHROPIC_API_KEY") openai_api_key = os.getenv("OPENAI_API_KEY") deepseek_api_key = os.getenv("DEEPSEEK_API_KEY") openrouter_api_key = os.getenv("OPENROUTER_API_KEY") @@ -46,14 +43,10 @@ def from_env(cls) -> "Config": ai_mode = AIMode.STANDARD logger.info("Warning: Invalid AI_MODE '%s', using %s", ai_mode_str, ai_mode.value) - if anthropic_api_key and not anthropic_api_key.startswith(("sk-ant-api03-", "sk-ant-")): - raise ValueError("Invalid ANTHROPIC_API_KEY format") - if openai_api_key and not openai_api_key.startswith("sk-"): raise ValueError("Invalid OPENAI_API_KEY format") return cls( - anthropic_api_key=anthropic_api_key, ai_mode=ai_mode, openai_api_key=openai_api_key, deepseek_api_key=deepseek_api_key, diff --git a/core/version_manifest.py b/core/version_manifest.py index 7263589..1e3324f 100644 --- a/core/version_manifest.py +++ b/core/version_manifest.py @@ -4,9 +4,10 @@ import re from functools import lru_cache from pathlib import Path +from typing import Literal import yaml -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator _SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$") @@ -53,6 +54,7 @@ class Components(BaseModel): class UiSchemaCompatibility(BaseModel): supported_versions: list[int] + supported_versions_by_kind: dict[Literal["analysis", "season", "weekly"], list[int]] default_version: int = Field(ge=1) @field_validator("supported_versions") @@ -72,6 +74,17 @@ def validate_default_version(cls, value: int, info) -> int: raise ValueError("default_version must be included in supported_versions") return value + @model_validator(mode="after") + def validate_kind_support(self) -> UiSchemaCompatibility: + expected_kinds = {"analysis", "season", "weekly"} + if set(self.supported_versions_by_kind) != expected_kinds: + raise ValueError("supported_versions_by_kind must define analysis, season, and weekly") + globally_supported = set(self.supported_versions) + for kind, versions in self.supported_versions_by_kind.items(): + if not versions or any(version not in globally_supported for version in versions): + raise ValueError(f"{kind} schema versions must be a non-empty subset of supported_versions") + return self + class Compatibility(BaseModel): ui_schema: UiSchemaCompatibility @@ -117,3 +130,7 @@ def get_supported_schema_versions() -> list[int]: def get_default_schema_version() -> int: return get_version_manifest().compatibility.ui_schema.default_version + + +def get_supported_schema_versions_for_kind(kind: Literal["analysis", "season", "weekly"]) -> list[int]: + return get_version_manifest().compatibility.ui_schema.supported_versions_by_kind[kind] diff --git a/docker-compose.yml b/docker-compose.yml index 3d41465..9df1fc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,6 @@ services: DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/${DATABASE_NAME:-paced_coach} REDIS_URL: redis://redis:6379/0 OPENAI_API_KEY: ${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} AI_MODE: ${AI_MODE:-cost_effective} LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-} LANGSMITH_PROJECT: ${LANGSMITH_PROJECT:-paced_coach_local} @@ -47,14 +46,6 @@ services: ALLOW_LOCAL_AUTH_PUBLIC_ACCESS: ${ALLOW_LOCAL_AUTH_PUBLIC_ACCESS:-false} ALLOW_LOCAL_DATA_DELETE: ${ALLOW_LOCAL_DATA_DELETE:-false} LOCAL_USAGE_SAFETY_BYPASS: ${LOCAL_USAGE_SAFETY_BYPASS:-false} - STRAVA_OAUTH_ENABLED: ${STRAVA_OAUTH_ENABLED:-false} - STRAVA_OAUTH_CLIENT_ID: ${STRAVA_OAUTH_CLIENT_ID:-} - STRAVA_OAUTH_CLIENT_SECRET: ${STRAVA_OAUTH_CLIENT_SECRET:-} - STRAVA_OAUTH_REDIRECT_URI: ${STRAVA_OAUTH_REDIRECT_URI:-} - WHOOP_OAUTH_ENABLED: ${WHOOP_OAUTH_ENABLED:-false} - WHOOP_OAUTH_CLIENT_ID: ${WHOOP_OAUTH_CLIENT_ID:-} - WHOOP_OAUTH_CLIENT_SECRET: ${WHOOP_OAUTH_CLIENT_SECRET:-} - WHOOP_OAUTH_REDIRECT_URI: ${WHOOP_OAUTH_REDIRECT_URI:-} PIXI_FROZEN: "true" depends_on: db: @@ -76,7 +67,6 @@ services: DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/${DATABASE_NAME:-paced_coach} REDIS_URL: redis://redis:6379/0 OPENAI_API_KEY: ${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} AI_MODE: ${AI_MODE:-cost_effective} LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-} LANGSMITH_PROJECT: ${LANGSMITH_PROJECT:-paced_coach_local} @@ -85,14 +75,6 @@ services: LOCAL_OWNER_EMAIL: ${LOCAL_OWNER_EMAIL:-local-owner@paced.local} ALLOW_LOCAL_DATA_DELETE: ${ALLOW_LOCAL_DATA_DELETE:-false} LOCAL_USAGE_SAFETY_BYPASS: ${LOCAL_USAGE_SAFETY_BYPASS:-false} - STRAVA_OAUTH_ENABLED: ${STRAVA_OAUTH_ENABLED:-false} - STRAVA_OAUTH_CLIENT_ID: ${STRAVA_OAUTH_CLIENT_ID:-} - STRAVA_OAUTH_CLIENT_SECRET: ${STRAVA_OAUTH_CLIENT_SECRET:-} - STRAVA_OAUTH_REDIRECT_URI: ${STRAVA_OAUTH_REDIRECT_URI:-} - WHOOP_OAUTH_ENABLED: ${WHOOP_OAUTH_ENABLED:-false} - WHOOP_OAUTH_CLIENT_ID: ${WHOOP_OAUTH_CLIENT_ID:-} - WHOOP_OAUTH_CLIENT_SECRET: ${WHOOP_OAUTH_CLIENT_SECRET:-} - WHOOP_OAUTH_REDIRECT_URI: ${WHOOP_OAUTH_REDIRECT_URI:-} PIXI_FROZEN: "true" depends_on: db: @@ -114,7 +96,6 @@ services: DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/${DATABASE_NAME:-paced_coach} REDIS_URL: redis://redis:6379/0 OPENAI_API_KEY: ${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} AI_MODE: ${AI_MODE:-cost_effective} LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-} LANGSMITH_PROJECT: ${LANGSMITH_PROJECT:-paced_coach_local} diff --git a/docs/assets/readme/paced-coach-coach.png b/docs/assets/readme/paced-coach-coach.png index c44d785..6eb9e0a 100644 Binary files a/docs/assets/readme/paced-coach-coach.png and b/docs/assets/readme/paced-coach-coach.png differ diff --git a/docs/assets/readme/paced-coach-dashboard.png b/docs/assets/readme/paced-coach-dashboard.png index 11ca8fb..68b06fe 100644 Binary files a/docs/assets/readme/paced-coach-dashboard.png and b/docs/assets/readme/paced-coach-dashboard.png differ diff --git a/docs/assets/readme/paced-coach-hero.png b/docs/assets/readme/paced-coach-hero.png index a9efd7d..8d75cdf 100644 Binary files a/docs/assets/readme/paced-coach-hero.png and b/docs/assets/readme/paced-coach-hero.png differ diff --git a/docs/assets/readme/paced-coach-plan.png b/docs/assets/readme/paced-coach-plan.png index f2a388c..29bb639 100644 Binary files a/docs/assets/readme/paced-coach-plan.png and b/docs/assets/readme/paced-coach-plan.png differ diff --git a/docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md b/docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md new file mode 100644 index 0000000..aae0302 --- /dev/null +++ b/docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md @@ -0,0 +1,95 @@ +--- +date: 2026-07-13 +topic: athlete-first-oss-release +--- + +# Athlete-First OSS Release + +> **Superseded connector decision (2026-07-13):** v2.2.0 ships without Strava, WHOOP, daily sync, or weekly recap. References below to optional connectors record the original requirements discovery; the provider-free decision in `agents_docs/roadmap/decision_log.md` governs implementation and launch copy. + +## Problem Frame + +paced.coach has evolved from a Garmin-connected AI coach experiment into a complete local-first coaching application. The current product is already useful without connected training platforms, but its public message and release materials do not yet make that strength unmistakable. + +The first polished open-source release is for technically confident, self-coached endurance athletes who are willing to run a local app and provide an LLM API key. They should understand that the product coaches from their profile, goals, competitions, constraints, and training availability. Strava and WHOOP can enrich that context, but they are not prerequisites for receiving a useful plan or coaching support. + +## Requirements + +**Product Promise** + +- R1. Public positioning must lead with paced.coach as a powerful AI endurance coach, not as a data connector, dashboard, or developer showcase. +- R2. The recurring product message must make the boundary explicit: **no wearable required**. Strava and WHOOP are optional context enhancements. +- R3. Product copy must not imply that the coach works without information. It must explain that the baseline coaching context comes from athlete-provided profile, goals, competitions, constraints, training history when available, and weekly availability. +- R4. Technical architecture and local data ownership must support the product story without displacing athlete value from the headline. + +**First-Use Experience** + +- R5. After completing local setup, a new user must be able to enter the minimum useful athlete context and generate a personal season roadmap plus a living 28-day training block without connecting Strava or WHOOP. +- R6. The generated plan must lead naturally into an ongoing coach conversation so the user can ask questions and discuss or adapt the plan. +- R7. The optional connector path must be visibly secondary and must explain the additional context each provider contributes without suggesting that disconnected coaching is inferior or incomplete. +- R8. A synthetic demo and current screenshots must let prospective users inspect the dashboard, calendar, plan, and coach experience without exposing private athlete data. + +**Release Quality** + +- R9. The public release must provide a reproducible local happy path, clear prerequisites, honest AI limitations, and troubleshooting guidance appropriate for technically confident users. The first-use path must handle missing prerequisites, insufficient athlete context, failed plan generation, and disconnected or failing optional providers without implying that a provider connection is mandatory. +- R10. Before publication, the repository must pass its backend and frontend verification suites and the public-release audits for tracked and historical secrets, private athlete data, and generated artifacts. Audit evidence must be recorded without printing secret values or private data into logs or release materials. +- R11. Legal and privacy content must remain factual local-first operational drafts. Unresolved facts must remain explicit, and publishing the GitHub release or beginning broader promotion must wait for the external legal review required by the repository's legal guardrails. +- R12. The release must include clear known limitations and must not imply medical, diagnostic, fully autonomous, or production-hosted-service capabilities. The no-login application must remain bound to loopback by default and must not be presented as safe for direct public-network exposure. + +**Launch Narrative and Distribution** + +- R13. The launch story must frame paced.coach as the evolution of the earlier Garmin AI Coach: a good coach should not depend on one wearable or training platform. +- R14. The GitHub release and a clean-install smoke test must be completed before broader promotion begins. +- R15. The follow-up Medium article and Reddit posts must lead with the athlete problem and the no-wearable-required coaching result, then use the complete local-first end-to-end architecture as supporting proof and a secondary technical story. +- R16. Launch materials must show the concrete product experience: athlete setup, season roadmap, 28-day calendar, dashboard, and continuing coach chat. + +## Success Criteria + +- A technically confident athlete can follow the public setup from a clean environment and reach a generated season roadmap and 28-day plan without connecting a provider. +- The same athlete can continue from the plan into a useful coach conversation. +- A reader can explain the product in one sentence without describing it as a Garmin, Strava, or WHOOP application. +- README, landing/demo surfaces, screenshots, release notes, Medium article, and Reddit posts consistently communicate **no wearable required** and present connectors as optional. +- Release verification and public-release audits have recorded passing evidence, with any accepted limitations disclosed. +- The public repository contains no secrets, private athlete data, or unreviewed generated personal artifacts. +- Setup instructions and launch materials do not encourage exposing the no-login application beyond the local machine. +- External legal review has been completed before the GitHub release and broad Medium and Reddit promotion, with required corrections or disclaimers incorporated. + +## Scope Boundaries + +- No hosted multi-user service, public no-login deployment, managed authentication, or payment flow is part of this release. +- No German localization is required for launch. +- No new wearable or training-platform connector is required. +- Strava and WHOOP do not need to become the primary onboarding path or the primary launch story. +- One-click installation for nontechnical users is not required; setup should be dependable and well documented for the stated technical audience. +- The release does not require resolving every long-term LangGraph or plan-first technical-debt item. +- Broad promotion does not begin until the release and clean-install smoke test are complete. + +## Key Decisions + +- **Athlete-first positioning:** The durable value is the coaching loop; the implementation stack is evidence that the product is real and complete. +- **Technically confident initial audience:** This keeps local-first ownership practical without expanding the release into a consumer installer project. +- **No wearable required:** This is more direct and positive than “works without connected data” or “manual-first coaching.” +- **Personal plan as first success:** The season roadmap and 28-day block demonstrate coaching value more clearly than beginning with an empty chat or demo-only experience. +- **Evolution narrative:** The Garmin origin supplies a credible reason for the product direction without making Garmin the current identity. +- **Release before promotion:** Public storytelling should point to a tested artifact rather than create pressure around an unfinished release candidate. + +## Dependencies / Assumptions + +- Users in the initial audience can install local prerequisites, run the documented stack, and configure an LLM API key. +- Useful disconnected coaching depends on users providing sufficiently rich and accurate context. +- Provider integrations remain read-only context sources and failures remain non-blocking for manual planning. +- Legal review and any unresolved business facts may constrain how broadly the release is promoted; unresolved items must be disclosed rather than silently assumed complete. + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R9, R10][Needs research] Which clean environments and operating systems must be exercised for the release smoke-test matrix? +- [Affects R5, R9][Needs research] What exact minimum athlete context is required by the current product to generate a useful provider-free plan, and does the UI communicate missing context clearly? +- [Affects R10][Needs research] Which repository-history and generated-artifact audit commands provide sufficient recorded evidence for the public release gate? +- [Affects R14][Technical] Should the release use the existing `2.2.0` version or introduce a new release version after final changes? +- [Affects R15][Needs research] Which relevant subreddits permit project posts, and what self-promotion rules apply at launch time? + +## Next Steps + +-> `/ce:plan` for structured release planning. diff --git a/docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md b/docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md new file mode 100644 index 0000000..efcfd74 --- /dev/null +++ b/docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md @@ -0,0 +1,124 @@ +--- +date: 2026-07-19 +topic: head-coach-agent-architecture +--- + +# Persistent Head Coach Agent Architecture + +## Problem Frame + +paced-coach is now intentionally useful without connected activity or recovery providers. The current AI workflow still reflects the earlier provider-first product: every full planning run executes provider-oriented summarizers and domain experts, then performs synthesis and several LLM formatting passes. With no provider data, these stages add latency and cost without adding corresponding coaching evidence. + +The product needs one accountable coaching intelligence that works from user-declared goals, availability, constraints, calendar state, prior coaching decisions, and optional connected data. The architecture must make ownership explicit, preserve the local-first promise, recover safely from long-running failures, and improve through measurable evaluations rather than additional mandatory agents. + +## Requirements + +**Coaching ownership** + +- R1. One logically persistent Head Coach identity and context must own the final coaching judgment across initial planning, coach chat, weekly recap, weekly adaptation, and material replanning. Persistence means durable continuity across invocations, not an always-running process. +- R2. User-declared facts must remain distinguishable from coach interpretations and optional provider observations; the coach must not present an inference as an athlete-provided fact or overwrite a user-declared fact without explicit user confirmation. +- R3. Specialists must be optional consultations selected when relevant, return advice to the Head Coach, and never directly mutate canonical athlete or plan state. +- R4. The Head Coach must ask a focused question when a material coaching decision cannot be made responsibly from available context instead of fabricating missing measurements or history. + +**Local-first source of truth** + +- R5. The local application database must remain canonical for the Athlete Record, Coach Model, Season Strategy, active Execution Plan, calendar operations, and Decision Ledger. +- R6. Agent execution state and derived agent memory must not become competing sources of truth for canonical coaching artifacts. +- R7. Strava, WHOOP, and future providers must remain optional read-only evidence sources. Their absence must not trigger empty provider-analysis stages or weaken the core planning and coaching capabilities. + +**Agent behavior and control** + +- R8. The Head Coach must receive the full available coaching context and a bounded set of atomic domain tools; deterministic code must validate and commit writes while the model owns coaching judgment. +- R9. Initial plan generation may commit its generated artifacts because the explicit generation command authorizes that action. Later material changes to an active plan must be presented as an approve, edit, or reject proposal before commit. +- R10. Model and reasoning configuration must follow the semantic responsibility of the task. Deep reasoning is reserved for consequential planning and replanning; formatting, labels, and other deterministic transformations must not consume deep-reasoning calls. +- R11. Web research and other external tools must be unavailable by default and exposed only for an explicit research need, such as event rules or course characteristics. + +**Durability and product experience** + +- R12. Long-running coaching work must be checkpointed durably and resumable after worker or application failure without repeating already completed expensive work. +- R13. The runtime must support durable clarification and approval pauses, idempotent resume, and protection against duplicate commits or events. +- R14. The UI must receive meaningful streaming lifecycle events such as understanding context, consulting a specialist, drafting, awaiting input, reviewing, and committing, rather than exposing framework node names as the product model. +- R15. Existing local plans, coaching history, and public API/UI contracts must remain readable during incremental migration; the architecture change must not require a destructive data rewrite. + +**Output and quality** + +- R16. The Head Coach must produce canonical coaching artifacts through a validated commit contract containing stable artifact identity, rich coaching content, typed calendar operations, semantic presentation intent, assumptions, risks, unresolved questions, and a Decision Ledger entry. +- R17. Rich LLM-driven UI composition must remain a core product capability. Dedicated deep-reasoning formatter agents must not be mandatory in the target path: the Head Coach should normally emit semantic component intent with its artifact, deterministic versioned React renderers own implementation and safety, and an optional low-cost UI Composer may reorganize presentation without changing coaching semantics. Invalid agent output must enter a bounded LLM repair loop with concrete validation feedback; if repair fails, the run fails visibly and no replacement content is invented or committed. +- R18. Architecture and model-policy changes must be evaluated against a provider-free baseline for coaching quality, constraint adherence, tool trajectory, latency, token use, cost, failure recovery, and unauthorized mutations. +- R19. Deep Agents must first be evaluated behind an isolated adapter or specialist spike that does not block the core Head Coach migration. It becomes a production dependency only if it measurably improves a long-horizon use case over the simpler Head Coach runtime without compromising local ownership. +- R20. Existing coaching-safety behavior for pain, injury risk, medical uncertainty, dangerous requests, and out-of-scope advice must be preserved or strengthened and covered by release-gating evaluations. + +## Success Criteria + +- A user can generate a useful season strategy and 28-day execution plan using only declared goals, constraints, availability, and local calendar context. +- A provider-free run performs no empty metrics, physiology, or activity specialist work. +- A failed long-running run resumes from a durable checkpoint without repeating completed model calls or committing duplicate artifacts. +- Coach chat, initial planning, and replanning present one coherent Head Coach identity and share the same canonical coaching context. +- Material changes to an active plan cannot be committed without the required user decision. +- The UI retains rich plan-specific cards, callouts, tables, checklists, timelines, and disclosures without requiring dedicated deep-reasoning formatter calls. +- A curated evaluation suite demonstrates equal or better coaching quality than the current full-run baseline while materially reducing unnecessary calls, latency, and token use. +- Optional provider evidence can improve a decision when connected, but connecting a provider is never required to access planning, recap, or coach-chat capabilities. + +## Scope Boundaries + +- No mandatory Strava, WHOOP, Garmin, or other provider integration is reintroduced. +- No general-purpose autonomous multi-agent organization is built; specialists exist only for bounded coaching or research consultations. +- No provider-hosted conversation state becomes the canonical application memory. +- No LangGraph or LangSmith cloud deployment is required for the local open-source release. +- No local no-login service is exposed beyond loopback as part of this work, and sensitive athlete context, credentials, or raw model reasoning may not be added to logs or public traces. +- No destructive migration or rewrite of existing local plans and coaching history is authorized by this architecture decision. +- The first delivery does not need asynchronous Deep Agents subagents, arbitrary shell access, or a general virtual filesystem. + +## Key Decisions + +- **One accountable Head Coach:** Many agents may advise, but one agent owns the coaching judgment and final proposal. +- **Database-owned domain truth:** PostgreSQL stores canonical coaching artifacts; runtime checkpoints store execution progress; optional agent memory stores only derived, replaceable memory. +- **Tools over mandatory stages:** The coach chooses atomic capabilities based on the actual task and available evidence instead of traversing a provider-shaped fixed pipeline. +- **Durable local runtime:** LangGraph remains the orchestration kernel for checkpoints, resume, interrupts, and streaming, while the standard agent loop moves to LangChain's current agent abstraction. +- **Explicit mutation authority:** Deterministic application code owns validation, idempotency, persistence, and authorization boundaries around agent-proposed writes. +- **Reasoning by responsibility:** Run profiles reflect the consequence and cognitive depth of a task; the system does not use maximum reasoning for every node. +- **Deep Agents by evidence:** Adopt useful patterns immediately, but gate the package itself behind a benchmarked specialist spike. +- **Safety survives simplification:** Removing mandatory agents and deep-reasoning formatter stages must not remove established coaching-safety boundaries, provenance, or rich UI composition. +- **Fail fast, let the agent repair:** Deterministic code reports contract violations precisely but does not manufacture substitute coaching or presentation content. The responsible agent gets a bounded opportunity to correct its output; exhausted repair leaves canonical state unchanged. +- **Incremental replacement:** Build a vertical Head Coach slice beside the legacy workflow, compare it against the recorded baseline, then remove old stages only after parity and compatibility are demonstrated. + +## High-Level Ownership Model + +```mermaid +flowchart TD + U[User command, chat, or calendar event] --> HC[Persistent Head Coach] + HC -->|reads| W[Local Coaching Workspace] + HC -->|consults when useful| S[Read-only Specialists] + S -->|advice only| HC + HC -->|proposal| G{Mutation policy} + G -->|initial generation authorized| C[Validated deterministic commit] + G -->|material active-plan change| A[Approve, edit, or reject] + A -->|approved or edited| C + C --> W + W --> DB[(Canonical local database)] + P[Optional provider observations] --> W + LG[LangGraph checkpoints and runtime state] -. execution progress only .-> HC +``` + +## Dependencies / Assumptions + +- The existing coach context, event store, plan persistence, calendar, and versioned UI contracts can be evolved incrementally rather than replaced in one release. +- Current OpenAI, LangChain, and LangGraph integrations remain available, but provider-side response state is treated as an optimization at most, never as durable ownership. +- The successful provider-free GPT run remains available as the initial quality, latency, token, and cost comparison point. + +## Outstanding Questions + +### Resolve Before Planning + +- None. + +### Deferred to Planning + +- [Affects R5, R6, R12][Technical] Define the exact boundary between existing domain tables, LangGraph checkpoint persistence, and any optional long-term agent store. +- [Affects R14, R15][Technical] Map new lifecycle events and canonical artifacts onto the existing API, worker, and versioned UI contracts without breaking current clients. +- [Affects R16, R17][Technical] Define the semantic component catalog and determine when direct Head Coach presentation intent is sufficient versus an optional constrained UI Composer. +- [Affects R18, R19][Needs research] Define acceptance thresholds and representative cases for the Head Coach and Deep Agents comparison. + +## Next Steps + +-> `/ce:plan` for structured implementation planning. diff --git a/docs/local-first/ai-coaching-limitations.md b/docs/local-first/ai-coaching-limitations.md index 0573758..169ec72 100644 --- a/docs/local-first/ai-coaching-limitations.md +++ b/docs/local-first/ai-coaching-limitations.md @@ -7,18 +7,19 @@ paced.coach is training-support software, not medical care. - Turn your saved profile, goals, constraints, races, and plan history into coaching context. - Generate a season roadmap and a 28-day execution block. - Answer plan questions and propose adaptations. -- Use Strava and WHOOP context when you intentionally configure and connect those providers. + +No wearable is required. An OpenAI API key plus athlete-declared training history, goals, availability, constraints, and feedback can support a specific, useful plan. Version 2.2.0 does not connect to external training-data providers. ## What The Coach Must Not Claim -When Strava/WHOOP are not connected, outputs must not claim knowledge of: +Outputs must not claim knowledge of information the athlete did not provide, including: - recent activity history - training load or compliance - sleep, HRV, recovery, or readiness trends - injury status beyond what you explicitly entered -When connected data is stale, partial, or unavailable, the coach should say so and reason with uncertainty. +When athlete-provided context is stale, partial, or unavailable, the coach should say so and reason with uncertainty. ## Human Responsibility @@ -28,17 +29,15 @@ Consult a qualified professional before making medical, rehabilitation, nutritio ## LLM Data Boundary -Plan generation and coaching send relevant prompt context to the configured LLM provider. That context can include profile details, goals, constraints, plan content, coach history, and connected training data. +Plan generation and coaching send relevant prompt context to OpenAI. That context can include profile details, goals, constraints, plan content, and coach history. -Do not enter information you do not want sent to your configured LLM provider. +Do not enter information you do not want sent to OpenAI. ## Local-First Boundary The default app stores data locally and has no hidden telemetry requirement. External network paths are: -- the configured LLM provider -- Strava, if OAuth is configured and connected -- WHOOP, if OAuth is configured and connected +- OpenAI API calls - LangSmith, if `LANGSMITH_API_KEY` is configured -Leave optional integrations unset if you do not want those paths. +Leave LangSmith unset if you do not want the optional tracing path. diff --git a/docs/local-first/connect-strava.md b/docs/local-first/connect-strava.md deleted file mode 100644 index 8e44849..0000000 --- a/docs/local-first/connect-strava.md +++ /dev/null @@ -1,82 +0,0 @@ -# Connect Strava Locally - -Strava is an optional data connector. It is not used for login, and the manual profile/plan flow works without it. - -## What Strava Adds - -- Activity history and recent execution context. -- Better daily sync and weekly recap evidence when you have recent activities. -- No recovery, sleep, HRV, or readiness data. Use WHOOP for that context if desired. - -## Create A Strava App - -1. Open the Strava developer settings and create an application. -2. Set the callback domain to `localhost` or `127.0.0.1`. -3. Use this local callback URL: - -```text -http://localhost:3000/app/api/oauth/strava/callback -``` - -Strava's OAuth docs state that `localhost` and `127.0.0.1` are allow-listed callback domains. - -## Environment - -Set these values in the root `.env`: - -```bash -FERNET_KEY= -STRAVA_OAUTH_ENABLED=true -STRAVA_OAUTH_CLIENT_ID= -STRAVA_OAUTH_CLIENT_SECRET= -STRAVA_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/strava/callback -``` - -Set this value in `web/app/.env.local`: - -```bash -NEXT_PUBLIC_STRAVA_OAUTH_ENABLED=true -``` - -Generate a local token-encryption key with: - -```bash -pixi run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -``` - -If you already connected providers, do not change `FERNET_KEY` unless you are willing to reconnect them. Existing encrypted tokens cannot be decrypted with a new key. - -## Requested Scope - -The app requests: - -```text -activity:read_all -``` - -This is intentionally narrow for this product path: it reads activity history, including private activities, so the coach can reason about actual training execution. The app does not request write scopes. - -## Connect - -1. Start the local stack with `make start`. -2. Open `http://localhost:3000/app/settings`. -3. Click `Connect Strava`. -4. Approve the requested scope. -5. Return to Settings and confirm Strava is active. - -The backend stores encrypted access and refresh tokens in local Postgres. OAuth state is random, single-use, and expires after 10 minutes. - -## Disconnect Or Reconnect - -- Use `Settings -> Disconnect Strava` to remove local Strava tokens and invalidate pending Strava OAuth sessions. -- The app attempts Strava deauthorization. If provider revocation fails, local tokens are still removed and the failure is logged without token values. -- If Settings shows `partial_permissions`, reconnect and approve `activity:read_all`. -- If Settings shows `token_expired` or `stale`, reconnect Strava. - -## Daily Sync And Weekly Recap - -Daily sync and weekly recap are optional connected-mode actions. They should run only when at least one connected training provider is usable. Without Strava/WHOOP, manual plan generation and coach chat still work, but the coach must not claim recent activity or recovery trends. - -## References - -- Strava OAuth authentication docs: https://developers.strava.com/docs/authentication/ diff --git a/docs/local-first/connect-whoop.md b/docs/local-first/connect-whoop.md deleted file mode 100644 index 24aec62..0000000 --- a/docs/local-first/connect-whoop.md +++ /dev/null @@ -1,82 +0,0 @@ -# Connect WHOOP Locally - -WHOOP is an optional data connector. It is not used for login, and the manual profile/plan flow works without it. - -## What WHOOP Adds - -- Recovery, cycles, workout, sleep, profile, and body-measurement context. -- Better daily sync and weekly recap evidence when recovery/readiness data is available. -- No Strava activity stream replacement. Use Strava for activity-history context if desired. - -## Create A WHOOP App - -1. Open the WHOOP Developer Dashboard and create an app. -2. Register this redirect URI: - -```text -http://localhost:3000/app/api/oauth/whoop/callback -``` - -WHOOP requires the redirect URI in the OAuth request to match a value registered in the developer dashboard. - -## Environment - -Set these values in the root `.env`: - -```bash -FERNET_KEY= -WHOOP_OAUTH_ENABLED=true -WHOOP_OAUTH_CLIENT_ID= -WHOOP_OAUTH_CLIENT_SECRET= -WHOOP_OAUTH_REDIRECT_URI=http://localhost:3000/app/api/oauth/whoop/callback -``` - -Set this value in `web/app/.env.local`: - -```bash -NEXT_PUBLIC_WHOOP_OAUTH_ENABLED=true -``` - -Generate a local token-encryption key with: - -```bash -pixi run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -``` - -If you already connected providers, do not change `FERNET_KEY` unless you are willing to reconnect them. Existing encrypted tokens cannot be decrypted with a new key. - -## Requested Scopes - -The app requests: - -```text -read:recovery read:cycles read:workout read:sleep read:profile read:body_measurement offline -``` - -`offline` is required so WHOOP returns a refresh token for local reconnect-free operation. The app does not use WHOOP as an identity provider. - -## Connect - -1. Start the local stack with `make start`. -2. Open `http://localhost:3000/app/settings`. -3. Click `Connect WHOOP`. -4. Approve the requested scopes. -5. Return to Settings and confirm WHOOP is active. - -The backend stores encrypted access and refresh tokens in local Postgres. OAuth state is random, single-use, and expires after 10 minutes. - -## Disconnect Or Reconnect - -- Use `Settings -> Disconnect WHOOP` to remove local WHOOP tokens and invalidate pending WHOOP OAuth sessions. -- The app attempts WHOOP access revocation. If provider revocation fails, local tokens are still removed and the failure is logged without token values. -- If Settings shows `token_expired` or `stale`, reconnect WHOOP. -- WHOOP refresh responses can rotate refresh tokens; the backend serializes refresh updates for one local owner/provider. - -## Daily Sync And Weekly Recap - -Daily sync and weekly recap are optional connected-mode actions. They should run only when at least one connected training provider is usable. Without Strava/WHOOP, manual plan generation and coach chat still work, but the coach must not claim recent activity or recovery trends. - -## References - -- WHOOP OAuth docs: https://developer.whoop.com/docs/developing/oauth/ -- WHOOP API scopes: https://developer.whoop.com/api diff --git a/docs/local-first/data-preservation.md b/docs/local-first/data-preservation.md index 7b4381f..db33be3 100644 --- a/docs/local-first/data-preservation.md +++ b/docs/local-first/data-preservation.md @@ -28,7 +28,7 @@ Use the reported `Owner user id` as: LOCAL_OWNER_USER_ID= ``` -Then restart the API/web app. Your active season plan, weekly plan, coach threads, competitions, and connected-provider state should resolve through that local owner. +Then restart the API/web app. Your active season plan, weekly plan, coach threads, and competitions should resolve through that local owner. ## Backup First @@ -70,9 +70,17 @@ The script runs in one database transaction. If any update fails, the transactio The script updates `user_id` references only. It does not delete users, plans, provider credentials, jobs, coach threads, or local usage history. -## Schema Baseline Status +## Schema Migration Status -Fresh public installs use a single Alembic baseline revision: `001_initial_local_first`. New contributors should not need to replay historical private migrations. +Fresh public installs first create the application schema at `001_initial_local_first`, then apply the additive `002_head_coach_checkpoints` upgrade. Revision `002` adds only LangGraph execution-state tables (`checkpoint_migrations`, `checkpoints`, `checkpoint_blobs`, and `checkpoint_writes`); it does not rewrite existing users, profiles, jobs, competitions, plans, coach threads, or coaching events. + +Run both revisions with: + +```bash +pixi run alembic upgrade head +``` + +The declared Alembic head is `002_head_coach_checkpoints`. If you used a pre-public branch before the migration history was squashed, your local database may still contain an older `alembic_version` even though the tables already match the local-first schema. In that case: @@ -86,4 +94,10 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/paced_coach \ pixi run alembic stamp --purge 001_initial_local_first ``` -Then `pixi run alembic upgrade head` should be a no-op. Do not use `stamp --purge` on an unknown database; it only changes Alembic bookkeeping and assumes the schema already matches the app. +Then run `pixi run alembic upgrade head` to create the additive checkpoint tables from revision `002`. Do not use `stamp --purge` on an unknown database; it only changes Alembic bookkeeping and assumes the revision-001 application schema already matches the app. + +## Head Coach Checkpoints + +The new checkpoint tables store disposable execution progress so a long-running Head Coach run can pause or resume. Canonical profiles, plans, and Coach Events remain in their existing domain tables. Backups of the Postgres volume include both kinds of data. + +Terminal-run checkpoints are normally removed after seven days. The protected local data reset removes checkpoint rows for the local owner together with their profile, plans, jobs, and coaching data while preserving the technical local-owner row. diff --git a/docs/local-first/privacy-and-data.md b/docs/local-first/privacy-and-data.md index 3194d07..69f8cde 100644 --- a/docs/local-first/privacy-and-data.md +++ b/docs/local-first/privacy-and-data.md @@ -9,10 +9,19 @@ The local app stores data in your local Postgres database: - generated plans - coach conversations - jobs and outputs -- optional provider tokens +- LangGraph Head Coach checkpoints used to pause or resume in-progress runs +- legacy connector records when upgrading an older development database Docker Compose persists Postgres in the `postgres_data` volume. +### Head Coach Checkpoint Content And Retention + +Head Coach checkpoints are local execution state, not a second source of truth. They can contain the working context needed to resume a run, including athlete-declared profile and goal context, plan drafts, model messages, tool results, and clarification state. They are stored in the local Postgres `checkpoint_*` tables alongside the app database; they are not stored in Redis or a hosted paced.coach service. + +Checkpoints for completed, failed, or cancelled runs are retained for a terminal debugging window of seven days by default and then removed by scheduled cleanup. `HEAD_COACH_CHECKPOINT_RETENTION_DAYS` can configure that window from 1 to 90 days. In-progress or awaiting-input checkpoints remain available so the run can resume. + +The protected local privacy reset deletes every checkpoint payload row whose owner-scoped thread belongs to the local owner. It also deletes the owner's profile, plans, jobs, coach conversations, and related app data, while preserving only the technical local-owner row so an explicitly configured `LOCAL_OWNER_USER_ID` does not become invalid. + ## Do Not Accidentally Delete Data Normal restarts preserve data. @@ -36,21 +45,15 @@ Use `LOCAL_OWNER_USER_ID` to point local mode at an existing `users.id` without Back up your DB before changing owner or reset settings. -## Provider Tokens - -Strava and WHOOP tokens are stored locally and encrypted with `FERNET_KEY`. - -If `FERNET_KEY` is lost, saved provider tokens cannot be decrypted. You can reconnect the provider after setting a new key, but old encrypted tokens are not recoverable. - -Disconnecting a provider from Settings deletes local tokens, invalidates pending local OAuth sessions for that provider, and attempts provider-side revocation when the provider supports it. Revocation failures do not keep local tokens. +## Legacy Connector Records -The local privacy reset is disabled by default. If enabled, it deletes local profile, plans, coaching outputs, jobs, provider tokens, pending OAuth sessions, and legacy local usage rows. In local mode it preserves the technical local owner row so `LOCAL_OWNER_USER_ID` does not become a broken pointer. +Version 2.2.0 does not read, refresh, transmit, or expose external training-provider credentials. An older development database may still contain legacy encrypted credential or OAuth rows. The protected local privacy reset deletes those rows together with the local profile, plans, coaching outputs, jobs, and legacy usage rows. It preserves the technical local owner row so `LOCAL_OWNER_USER_ID` does not become a broken pointer. ## LLM Data Sharing -Plan generation and coaching send relevant prompt context to the configured LLM provider. This can include profile details, goals, constraints, plan content, and connected training context when enabled. +Plan generation and coaching send relevant prompt context to OpenAI. This can include profile details, goals, constraints, plan content, and coach history. -Do not enter private data that you do not want sent to your configured LLM provider. +Do not enter private data that you do not want sent to OpenAI. ## Tracing @@ -60,8 +63,8 @@ Leave `LANGSMITH_API_KEY` unset for the most local/private default. ## No Hidden Telemetry -Local-first setup does not require hosted telemetry. Optional provider OAuth, LLM calls, and LangSmith tracing are the external network paths relevant to user data. +Local-first setup does not require hosted telemetry. LLM calls and optional LangSmith tracing are the external network paths relevant to user data. -## Manual Mode Boundaries +## Provider-Free Boundaries -When Strava/WHOOP are not connected, the system should use declared profile/goals/context only. It must not claim recent activity history, training load, compliance, sleep, HRV, recovery, or readiness trends. +The system uses declared profile, goals, races, constraints, notes, generated plans, and coach history. It must not claim recent activity history, training load, compliance, sleep, HRV, recovery, or readiness trends unless the athlete explicitly supplied them. diff --git a/docs/local-first/release-checklist.md b/docs/local-first/release-checklist.md new file mode 100644 index 0000000..db472dd --- /dev/null +++ b/docs/local-first/release-checklist.md @@ -0,0 +1,50 @@ +# Public Release Checklist + +Use this checklist for each public release. It is a release gate, not a general development checklist. + +## Safety Boundary + +- [ ] Work from a dedicated release branch and a fixed candidate commit. +- [ ] Do not read, copy, reset, migrate, or delete the maintainer's local athlete database. +- [ ] Keep `.env`, `web/app/.env.local`, database volumes, logs, exports, and model traces out of release evidence. +- [ ] Keep the no-login app bound to loopback. + +## Repository Audit + +- [ ] The working tree is clean. +- [ ] `scripts/release_audit.sh` passes using pinned Gitleaks for the clean candidate commit, including local and remote-tracking refs. +- [ ] The current working-tree candidate export passes the pinned Gitleaks content scan. +- [ ] Any scanner report under `.tmp/release-audit/` is fully redacted and remains untracked. +- [ ] README screenshots are recaptured from sanitized schema-v3 fixtures and inspected visually for private athlete data. The July pre-Head-Coach assets are not candidate evidence. +- [ ] Any suspected credential has been rotated before further investigation. +- [ ] No history rewrite or destructive cleanup was performed without explicit maintainer approval. + +## Automated Verification + +- [ ] Backend Ruff checks pass. +- [ ] Backend MyPy checks pass. +- [ ] Python tests pass. +- [ ] Frontend lint, type-check, tests, and production build pass. +- [ ] Version-manifest and version-governance checks pass. +- [ ] GitHub CI passes for the exact candidate commit. + +## Clean-Install Acceptance + +- [ ] A fresh Ubuntu/Linux environment follows only the public setup documentation. +- [ ] The smoke stack uses a unique Compose project and disposable volumes. +- [ ] Only `OPENAI_API_KEY` is configured; optional observability remains unset. +- [ ] A synthetic profile and A-race produce schema-v3 Season Strategy and 28-day Execution artifacts. +- [ ] Coach chat answers a plan-specific question without provider-derived claims. +- [ ] Normal restart preserves the disposable synthetic plan and does not duplicate canonical artifacts. +- [ ] Cleanup changes only the disposable Compose namespace. +- [ ] Non-sensitive results are recorded for the exact candidate commit. + +## Legal And Publication Gate + +- [ ] External legal review is complete and required corrections are applied. +- [ ] `LEGAL_TODO.md` reflects the actual remaining work. +- [ ] Release notes state the localhost, AI, medical, privacy, and provider-free boundaries. +- [ ] The GitHub release exists as a reviewed draft tied to the audited commit. +- [ ] The maintainer gives an explicit final Go before publishing the immutable release. + +Any unchecked required item is a No-Go. After publication, preserve the tag. Correct defects with a newly audited patch release instead of moving or reusing the published tag. diff --git a/docs/local-first/setup.md b/docs/local-first/setup.md index e371082..13783b0 100644 --- a/docs/local-first/setup.md +++ b/docs/local-first/setup.md @@ -7,7 +7,7 @@ This app is designed for a single local operator on `localhost`. - Docker with Docker Compose v2 - Pixi - Node.js 24 and npm -- One LLM API key: `OPENAI_API_KEY` for the default mode, or `ANTHROPIC_API_KEY` with `AI_MODE=anthropic` +- One OpenAI API key: `OPENAI_API_KEY` ## Setup @@ -25,22 +25,26 @@ for reproducible local setup. Open `http://localhost:3000/app`. +## First Useful Run + +No wearable is required. With an OpenAI API key configured: + +1. Describe your training history, availability, constraints, and primary goals in the athlete profile. +2. Add a target race or a primary goal. +3. Generate your season roadmap and 28-day execution block. +4. Open the plan calendar, then continue the same context in coach chat. + +Version 2.2.0 has no external training-data connectors. The complete release path is profile, goal/race, plan, calendar, and coach chat. + ## Required Configuration -Set `OPENAI_API_KEY` in `.env` for the default GPT/OpenAI routing: +Set `OPENAI_API_KEY` in `.env`: ```bash OPENAI_API_KEY=... AI_MODE=cost_effective ``` -Or use Anthropic routing: - -```bash -ANTHROPIC_API_KEY=... -AI_MODE=anthropic -``` - The default local mode is: ```bash @@ -71,24 +75,6 @@ Do not reset or recreate the database when preserving an existing training plan. Detailed backup and dry-run migration guidance lives in [data-preservation.md](data-preservation.md). -## Optional Strava/WHOOP - -Connected sources are optional. Manual plan generation works without them. - -To enable OAuth, generate a `FERNET_KEY`, configure the provider app callback URL, then set the provider env values. - -Local callbacks: - -```bash -http://localhost:3000/app/api/oauth/strava/callback -http://localhost:3000/app/api/oauth/whoop/callback -``` - -Detailed connector setup: - -- [connect-strava.md](connect-strava.md) -- [connect-whoop.md](connect-whoop.md) - ## Network Boundary The no-login app is for localhost only. Do not expose it on a LAN or public internet without adding authentication, TLS, and a separate security review. diff --git a/docs/plans/2026-07-13-001-feat-athlete-first-oss-release-plan.md b/docs/plans/2026-07-13-001-feat-athlete-first-oss-release-plan.md new file mode 100644 index 0000000..6334934 --- /dev/null +++ b/docs/plans/2026-07-13-001-feat-athlete-first-oss-release-plan.md @@ -0,0 +1,490 @@ +--- +title: "feat: Ship the athlete-first OSS release" +type: feat +status: active +date: 2026-07-13 +origin: docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md +deepened: 2026-07-13 +--- + +# feat: Ship the athlete-first OSS release + +## Superseding Provider-Free Decision — 2026-07-13 + +The maintainer selected a provider-free v2.2.0 after reviewing the current provider API terms. Strava, WHOOP, OAuth, imports, daily sync, and weekly recap are removed from the public runtime and launch claims. Legacy credential/schema rows remain only for non-destructive compatibility and are never read or transmitted by the release runtime. A future connector requires written provider permission or a clearly compatible contract plus a new legal/security review. + +This decision supersedes later references in this plan to optional connectors or connected regressions. Units 1–5 remain valid as historical execution evidence; Unit 6 closes against the provider-free product contract. + +## Overview + +Prepare and publish the first polished GitHub release of paced.coach as a powerful AI endurance coach for technically confident, self-coached athletes. The product must lead with the outcome — season roadmap, living 28-day plan, and continuing coach conversation — and repeat the boundary **no wearable required**. Version 2.2.0 is provider-free. + +The underlying no-provider product path already exists. This plan concentrates on proving it from a clean environment, aligning every public message, making the release audit repeatable, recording non-sensitive evidence, satisfying the legal gate, and only then publishing and promoting the release (see origin: `docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md`). + +## Problem Frame + +The repository currently describes a local-first application well, but some high-visibility copy still says “connected endurance coaching,” and the generation UI calls provider-free planning “Draft Mode.” Those phrases weaken the actual value proposition and can make Strava/WHOOP sound foundational. At the same time, the repository has a long pre-OSS Git history, no published tags, no durable release-audit runner, and no recorded clean-install acceptance result. A public campaign before closing those gaps would amplify an artifact whose strongest promise has not yet been demonstrated end to end. + +The initial audience accepts Docker, Node.js, Pixi, and an LLM API key. The release does not attempt to solve consumer-grade installation, public hosting, multi-user auth, or new integrations. + +## Requirements Trace + +- **R1–R4 — Product promise:** Athlete value leads; “no wearable required” is explicit; declared athlete context is accurately described; local-first architecture supports rather than replaces the headline. +- **R5–R8 — First-use experience:** A fresh user reaches a season roadmap and 28-day plan without providers, can continue into coach chat, sees connectors as secondary, and can inspect only synthetic public demo data. +- **R9–R12 — Release quality:** Setup and failure guidance are reproducible; code, history, secrets, private data, and artifacts are audited; legal review gates publication; known AI, medical, hosting, and loopback boundaries remain explicit. +- **R13–R16 — Launch:** The story connects the Garmin experiment to a platform-independent coach; a tested GitHub release precedes promotion; Medium and Reddit lead with athlete value and show the complete app experience. + +## Scope Boundaries + +- Do not expose the no-login app beyond loopback or add a hosted deployment path. +- Do not alter, migrate, reset, or inspect the maintainer's existing athlete database during release validation. +- Do not add hosted auth, payments, multi-user support, German localization, or new data connectors. +- Do not make daily sync or weekly recap provider-free; they remain optional connected-data features. +- Do not resolve unrelated LangGraph technical debt or redesign the application. +- Do not claim medical, diagnostic, autonomous, or device-derived knowledge the coach does not have. +- Do not publish the release tag or begin broad promotion before the external legal review is complete. +- Do not commit generated launch drafts, audit reports, local screenshots, or personal smoke-test data unless they have passed the public-artifact review. + +## Context & Research + +### Relevant Code and Patterns + +- `README.md` already documents the provider-free path as profile → competition/goal → plan → coach and clearly states the localhost safety boundary. +- `api/services/dashboard_state.py::_build_first_run_state` implements the first-run sequence and distinguishes declared-only evidence from connected evidence. +- `api/services/evidence_profile.py` and prompt-contract tests prevent unsupported activity, load, sleep, HRV, recovery, and readiness claims when no provider is connected. +- `api/services/coach_turn.py` allows coach chat without a training provider; provider gates remain limited to daily sync and weekly recap. +- `web/app/src/app/app/new/page.tsx` already loads profile, competitions, provider status, and LLM readiness, but its “Draft Mode” language makes the baseline path sound provisional. +- `web/app/src/app/demo/page.tsx` and `web/app/src/lib/demo/fixtures/v1/` provide the synthetic public preview and screenshot source. +- `docker-compose.yml` binds Postgres, Redis, and the API to `127.0.0.1`; `api/config.py` rejects unsafe local-auth contexts by default. +- `.github/workflows/ci.yml`, `Makefile`, and version-governance scripts provide the existing automated quality gate. +- `.gitleaks.toml` exists, but no repeatable release-audit runner or committed verification record exists. +- `config/version_manifest.yaml`, `pyproject.toml`, `pixi.toml`, and `web/app/package.json` already agree on release `2.2.0`; no Git tags or GitHub releases currently exist. +- No relevant institutional learnings exist under `docs/solutions/`. + +### External References + +- GitHub recommends drafting a release before publishing it; immutable releases lock tags and assets and generate release attestations: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository and https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases +- Current Gitleaks uses `git` and `dir` scan modes; `detect` and `protect` are deprecated. Redacted reporting prevents secret material from leaking into evidence: https://github.com/gitleaks/gitleaks +- Reddit's current spam policy prohibits repeated mass promotion and expects authentic participation plus community-rule checks: https://support.reddithelp.com/hc/en-us/articles/360043504051-Spam +- As researched on 2026-07-13, `r/running` prohibits self-promotion; `r/selfhosted` routes projects younger than three months to its current New Project Megathread; `r/opensource` permits limited promotion with the correct flair but prohibits AI-generated low-effort content. Rules must be checked again immediately before posting. + +## Key Technical Decisions + +| Decision | Rationale | +|---|---| +| Keep `2.2.0` for the first GitHub tag | All canonical manifests already carry `2.2.0`, there are no prior tags/releases, and the product is explicitly an evolution of the earlier coach rather than a semantic reset. | +| Treat Ubuntu/Linux as the verified first-release environment | CI and the existing shell/Docker workflow are Linux-grounded. macOS and Windows remain best-effort/unverified until exercised; this avoids an unsupported portability claim. | +| Validate with disposable infrastructure | A unique Compose project/volume namespace and synthetic athlete profile prevent the smoke test from reading or mutating the maintainer's local plans. | +| Keep the real-LLM smoke test manual and evidence-based | The acceptance criterion is product quality and cross-service behavior, not deterministic model prose. Automated tests continue to validate contracts; the release checklist records human review of the actual result. | +| Add a repeatable non-destructive audit runner | A scripted gate reduces omission risk and can cover both the working tree and all reachable Git history while redacting findings. | +| Stop rather than auto-remediate audit findings | Secret rotation, history rewriting, or deletion of local artifacts are destructive/high-impact decisions and require explicit maintainer approval. | +| Keep launch copy outside tracked source until publication | The repository deliberately removed internal marketing artifacts. Medium/Reddit drafts should be prepared in an ignored local workspace or the destination editor, then published by the maintainer after review. | +| Ship v2.2.0 provider-free | The declared-context product passed real-model acceptance on its own, while reviewed provider terms do not support the intended default AI-processing and launch posture. | + +## Open Questions + +### Resolved During Planning + +- **Minimum provider-free context:** Current readiness considers one physiology anchor, selected sport, days per week, time windows, and a primary goal; a competition can substitute for the goal anchor. The release smoke persona should populate all five areas plus one A-race to test the richest intended path without providers. +- **Clean-install matrix:** Ubuntu/Linux with Docker Compose v2, Node.js 24, Pixi, and one supported LLM key is the required release matrix. Other operating systems are documented as unverified rather than silently promised. +- **Release version:** Publish the first tag as `v2.2.0` if no feature or schema change forces a version bump during execution. +- **Reddit targets:** Exclude `r/running`; use only the current permitted surface for `r/selfhosted`; treat `r/opensource` as optional and require a genuinely maintainer-authored post; evaluate `r/SideProject` immediately before promotion. + +### Deferred to Implementation + +- **Audit findings:** The presence and severity of historical secrets or private artifacts can only be known by running the pinned audit. Any finding pauses the plan for rotation and remediation decisions. +- **Real model quality:** The provider-free plan and follow-up coach response must be judged during the isolated smoke test against the origin success criteria. +- **Legal corrections:** Counsel may require changes to public legal pages or distribution wording; those changes are applied and reverified before publication. +- **Final launch timing:** Choose dates only after the release candidate, legal gate, and GitHub draft release are ready. + +## Release Flow + +> This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce. + +```mermaid +flowchart TB + A[Audit runner and preflight] --> B[Dependency and CI readiness] + A --> C[Athlete-first message alignment] + C --> D[Provider-free first-use hardening] + B --> E[Isolated clean-install smoke] + D --> E + E --> F[Release evidence and legal review] + F --> G[Draft and publish GitHub v2.2.0] + G --> H[Medium follow-up] + G --> I[Rule-compliant Reddit posts] +``` + +Prose and unit dependencies below are authoritative if this diagram becomes stale. + +## Implementation Units + +- [x] **Unit 1: Add the non-destructive public-release audit gate** + +**Goal:** Make tracked-file, full-history secret, generated-artifact, workflow, and hosted-ops checks repeatable without exposing findings or touching local athlete data. + +**Requirements:** R10, R12 + +**Dependencies:** None + +**Files:** +- Create: `scripts/release_audit.sh` +- Create: `tests/test_release_audit_contract.py` +- Create: `docs/local-first/release-checklist.md` +- Modify: `.gitignore` +- Modify: `SECURITY.md` + +**Approach:** +- Use the pinned Gitleaks `v8.30.1` container and its supported `git` and `dir` modes with full redaction; keep reports under an ignored `.tmp/release-audit/` directory. The history scan must cover all reachable refs. The directory scan must run against an isolated export of tracked/release files rather than the live working directory so ignored `.env`, databases, caches, and athlete artifacts are never opened by the scanner. +- Supplement secret scanning with deterministic checks for tracked env files, database/export/cache paths, oversized/generated artifacts, workflow secret references, public-network bindings, and stale hosted auth/payment/deployment surfaces. +- Inventory ignored secret/data path names and release-risk categories without reading their contents; their mere local presence is expected and is not a failure unless they are tracked or included in a release artifact. +- Emit a concise pass/fail summary and filenames/rule identifiers only. Never echo candidate secret values or personal data. +- Default to read-only checks. Do not rotate keys, rewrite history, delete files, prune Docker state, or modify Git state. +- Document the halt path: rotate any exposed key first, then ask for explicit approval before history rewriting or destructive remediation. + +**Patterns to follow:** +- `.gitleaks.toml` for project allowlists and test-key exclusions. +- `scripts/check_version_governance.py` for clear `[OK]`/`[ERROR]` output and nonzero failure status. +- `SECURITY.md` for the existing public-release gate categories. + +**Test scenarios:** +- **Happy path:** A synthetic clean fixture repository containing only allowed example credentials passes and prints no secret-like values. +- **Error path:** A committed synthetic secret causes a nonzero result while output and stored reports redact the matched value. +- **Edge case:** An ignored local `.env` is reported by filename/category only, is never copied into the tracked-file scan export, and does not become tracked. +- **Integration:** A synthetic secret reachable only from a non-current Git ref is still found and redacted by the full-history scan. +- **Error path:** A tracked file under a forbidden private-data/generated-artifact path fails with the path and category only. +- **Safety:** Running the audit leaves tracked files, refs, Docker volumes, and local data directories unchanged. + +**Verification:** +- One documented audit entry point covers all categories listed in `SECURITY.md`, fails closed, produces only redacted ignored artifacts, and is safe to rerun. + +- [x] **Unit 2: Close dependency, CI, and version readiness** + +**Goal:** Start the release candidate from reviewed dependencies and a coherent `2.2.0` manifest before product-facing polish. + +**Requirements:** R9, R10, R14 + +**Dependencies:** Unit 1 audit must pass or have approved remediation before a release candidate proceeds. + +**Files:** +- Modify as required: `web/app/package.json` +- Modify as required: `web/app/package-lock.json` +- Verify: `.github/workflows/ci.yml` +- Verify: `config/version_manifest.yaml` +- Verify: `pyproject.toml` +- Verify: `pixi.toml` +- Verify: `web/app/src/lib/generated/version-manifest.ts` +- Modify: `CHANGELOG.md` + +**Approach:** +- Review and land the two open Dependabot updates independently; do not batch unrelated dependency churn. +- Preserve release version `2.2.0` unless implementation introduces product/schema behavior that warrants a new version; if it changes, update every governed manifest through the existing generator. +- Convert the Unreleased changelog into a user-facing `2.2.0` entry centered on provider-free coaching, local ownership, and optional connectors. +- Keep CI permissions read-only and retain all current backend, frontend, build, and governance gates. + +**Patterns to follow:** +- `agents_docs/ops/version_governance.md` and existing version-governance scripts. +- Focused Dependabot PRs #3 and #4 rather than manual broad upgrades. + +**Test scenarios:** +- **Happy path:** Updated lockfile installs reproducibly and all frontend checks/build pass. +- **Integration:** Release version remains identical across the canonical manifest, Python package, Pixi project, web package, and generated web artifact. +- **Regression:** Backend lint, type-check, tests, frontend lint/type-check/tests/build, and both version-governance checks remain green after dependency updates. +- **Error path:** Version drift or a missing renderer/fixture fails the existing governance gate before release. + +**Verification:** +- Dependabot PRs are resolved, main CI is green at the release-candidate commit, and `2.2.0` is consistent everywhere. + +- [x] **Unit 3: Align all public messaging around athlete value** + +**Goal:** Make “powerful coach, no wearable required” the consistent message across GitHub, metadata, social previews, demo, and roadmap sources of truth. + +**Requirements:** R1–R4, R7, R8, R13, R15, R16 + +**Dependencies:** Unit 1 provides the artifact-safety gate; this unit can otherwise proceed in parallel with Unit 2. + +**Files:** +- Modify: `README.md` +- Modify: `web/app/src/app/layout.tsx` +- Modify: `web/app/src/app/page.tsx` +- Modify: `web/app/src/app/demo/page.tsx` +- Modify: `web/app/public/og.svg` +- Modify after reviewed recapture: `docs/assets/readme/paced-coach-hero.png` +- Modify after reviewed recapture: `docs/assets/readme/paced-coach-dashboard.png` +- Modify after reviewed recapture: `docs/assets/readme/paced-coach-plan.png` +- Modify after reviewed recapture: `docs/assets/readme/paced-coach-coach.png` +- Modify: `docs/local-first/setup.md` +- Modify: `docs/local-first/ai-coaching-limitations.md` +- Modify: `agents_docs/roadmap/now.md` +- Modify: `agents_docs/roadmap/decision_log.md` +- Test: `web/app/src/tests/fixtures/validate-fixtures.ts` +- Create: `web/app/src/tests/public/messaging-contract.ts` +- Modify: `web/app/package.json` + +**Approach:** +- Lead with the concrete sequence: describe athlete context, generate a season roadmap and 28-day plan, keep talking to the coach. +- Repeat “no wearable required” near the first product promise and explain immediately that an LLM key and athlete-provided context are still required. +- Replace “connected endurance coaching” and “paywalled dashboard” as primary framing; local-first and full-stack architecture become proof points beneath the athlete outcome. +- Present Strava and WHOOP as opt-in additions for activity/recovery context, daily sync, and weekly recap — never as login or prerequisites. +- Keep screenshots and demo fixtures synthetic; update only the intentionally generated README assets after inspecting every frame for private data. +- Record athlete-first/no-wearable positioning as a durable decision and make the release the active roadmap focus. + +**Patterns to follow:** +- `README.md#First Useful Run` for the correct provider-free journey. +- `web/app/public/og.svg` for the existing season-roadmap/28-day-plan message hierarchy. +- `docs/local-first/ai-coaching-limitations.md` for accurate evidence boundaries. + +**Test scenarios:** +- **Content contract:** A dedicated messaging test verifies that README, root metadata, demo metadata, OpenGraph asset, and demo hero all state or directly support “no wearable required.” +- **Accuracy:** Every no-wearable claim also identifies declared athlete context and the LLM provider as necessary inputs. +- **Boundary:** Strava/WHOOP appear only as optional enhancements and no public copy promises daily sync/weekly recap without connected evidence. +- **Artifact safety:** Fixture validation passes and regenerated screenshots contain only the named synthetic persona and no email, UUID, trace ID, key, or private activity data. +- **Responsive/accessibility:** Revised hero and metadata-linked preview remain readable on mobile/desktop and the SVG retains meaningful accessible alt usage through metadata. + +**Verification:** +- A copy inventory finds no high-visibility “connected coach” or “Draft Mode” positioning, and a reader can summarize the product without naming Garmin, Strava, or WHOOP. + +- [x] **Unit 4: Harden the provider-free first-use and coach handoff** + +**Goal:** Ensure the in-app flow treats declared athlete context as a complete baseline path and handles missing prerequisites or downstream failures honestly. + +**Requirements:** R3, R5–R7, R9, R12 + +**Dependencies:** Unit 3 establishes the approved vocabulary. + +**Files:** +- Modify: `web/app/src/app/app/new/page.tsx` +- Modify as required: `web/app/src/components/dashboard/dashboard-client.tsx` +- Modify: `api/services/dashboard_state.py` +- Test: `tests/test_dashboard_state.py` +- Test: `tests/test_api_coach_turn_routes.py` +- Test: `tests/test_training_plan_prompt_contracts.py` +- Create or modify: `web/app/src/tests/onboarding/no-provider-first-run.ts` +- Modify: `web/app/package.json` + +**Approach:** +- Remove user-facing “Draft Mode” language. Say directly that planning uses saved profile, goals/races, availability, constraints, and generation notes now; connected sources add later evidence. +- Preserve the current backend state keys and provider-neutral evidence policy to avoid unnecessary API/schema churn. +- Keep plan generation available with sparse context but make the richer recommended profile fields explicit; do not add new deterministic coaching heuristics or provider gates. +- Maintain the ordered setup recovery states: missing LLM key → incomplete profile → missing goal/race → generate → open plan → ask coach. +- Confirm that plan-generation failures retain the user's saved profile/race context and provide an actionable retry path. +- Characterize coach chat without providers so a later refactor cannot silently reintroduce a connector requirement. + +**Patterns to follow:** +- `_build_first_run_state` and `profileCompleteness` for existing readiness guidance. +- `api/services/evidence_profile.py` for claim boundaries. +- `_ensure_connected_coach_chat_available` behavior and `require_training_provider=False` in the coach tool registry for provider-free chat. + +**Execution note:** Add characterization coverage before changing user-facing first-run behavior. + +**Test scenarios:** +- **Happy path:** With one LLM key, complete declared profile, primary goal/A-race, and no providers, first-run state points to plan generation and the generation screen is enabled. +- **Happy path:** After an active plan exists with no providers, first-run state points to the plan and exposes “Ask coach”; a mocked coach turn succeeds with declared-only evidence. +- **Error path:** With no LLM key, generation is blocked with the exact local configuration recovery step and no connector CTA. +- **Edge case:** With a primary goal but no competition, the user can proceed and sees reduced-specificity guidance rather than a provider requirement. +- **Edge case:** With a competition but no primary-goal text, the race anchors readiness and generation remains available. +- **Error path:** Context-fetch or generation failure shows a retryable error without discarding saved profile/competition data. +- **Boundary:** No-provider prompts forbid claims about recent activity, load, compliance, sleep, HRV, recovery, or readiness while allowing declared profile/goal claims. +- **Connected regression:** Existing Strava/WHOOP status, daily-sync, and recap gates continue to behave as optional connected features. + +**Verification:** +- The complete no-provider path is covered across backend state, frontend readiness, prompt boundaries, plan generation handoff, and coach chat without changing public schemas. + +- [x] **Unit 5: Execute and record an isolated clean-install release-candidate smoke** + +**Goal:** Prove the actual multi-service product path with a real supported LLM from a clean Linux environment while protecting all existing local data. + +**Requirements:** R5, R6, R8–R10, R14, R16 and all success criteria + +**Dependencies:** Units 1–4 complete; release-candidate CI green. + +**Files:** +- Modify: `docs/local-first/release-checklist.md` +- Create: `docs/releases/v2.2.0-verification.md` +- Modify as findings require: `README.md` +- Modify as findings require: `docs/local-first/setup.md` + +**Approach:** +- Clone the release candidate into a separate path on a clean Ubuntu/Linux environment and use a unique Compose project with disposable volumes; never point at existing volumes, `LOCAL_OWNER_USER_ID`, exports, or databases. +- Configure only the minimum local values and one real LLM key. Leave Fernet, Strava, WHOOP, and LangSmith unset. +- Follow the public docs exactly. Populate a synthetic but realistic athlete across all five readiness dimensions plus one A-race, generate a plan, inspect season and 28-day outputs, then ask the coach a plan-specific follow-up. +- Confirm that the app never asks for a connector, invents device-derived evidence, or exposes a service beyond loopback. +- Record versions, environment, commit SHA, elapsed setup outcome, page/flow outcomes, and redacted pass/fail notes. Do not commit prompts, model traces, keys, raw logs, database contents, or screenshots from this smoke run. +- Remove only the disposable smoke namespace after evidence is recorded; stop and ask before any command could affect non-disposable volumes. +- Capture a pre-run inventory of Compose project/volume names and compare it after cleanup. Any unexpected change outside the disposable namespace is a No-Go and must be investigated before continuing. + +**Patterns to follow:** +- `README.md#Quick Start` and `#First Useful Run` as the test script from the user's perspective. +- `tests/test_api_smoke_no_ai.py` for expected job lifecycle and immutable context snapshot behavior. +- `docs/local-first/data-preservation.md` for non-destructive ownership and backup boundaries. + +**Test scenarios:** +- **Clean setup:** Fresh clone plus documented prerequisites reaches healthy web/API/worker/database/Redis services without undocumented steps. +- **Provider-free E2E:** Synthetic profile and race generate a season roadmap and 28-day calendar with Strava/WHOOP disabled. +- **Coach continuation:** A question referencing a specific generated session receives a context-aware answer without provider claims. +- **Error recovery:** Temporarily missing LLM configuration produces actionable guidance; restoring it and restarting allows generation. +- **Network boundary:** Host-visible service bindings remain loopback-only. +- **Data isolation:** Existing local Compose projects, volumes, databases, env files, and athlete artifacts are unchanged before and after the run. +- **Restart durability:** A normal stop/start of the disposable stack preserves the synthetic profile and generated plan. + +**Verification:** +- `docs/releases/v2.2.0-verification.md` records a passing clean-install outcome for every origin success criterion with no sensitive evidence committed. + +- [ ] **Unit 6: Close legal, documentation, and release-candidate gates** + +**Goal:** Produce a release candidate that is technically complete, factually documented, externally reviewed, and ready to tag without unresolved blockers hidden in prose. + +**Requirements:** R9–R14 + +**Dependencies:** Units 1–5; external Germany-based legal review. + +**Files:** +- Modify: `LEGAL_TODO.md` +- Modify as counsel requires: `web/app/src/app/impressum/page.tsx` +- Modify as counsel requires: `web/app/src/app/privacy/page.tsx` +- Modify as counsel requires: `web/app/src/app/terms/page.tsx` +- Modify as counsel requires: `web/app/src/app/support/page.tsx` +- Modify as counsel requires: `web/app/src/app/delete/page.tsx` +- Modify: `CHANGELOG.md` +- Create: `docs/releases/v2.2.0.md` +- Modify: `docs/releases/v2.2.0-verification.md` + +**Approach:** +- Give counsel the actual local-first data flow, external processors, no-login/loopback boundary, deletion behavior, and planned distribution context; treat returned wording as an operational draft, not AI-generated legal advice. +- Resolve or explicitly retain every `LEGAL_TODO.md` item. No unchecked item may be silently described as complete in release notes. +- Write concise GitHub release notes: athlete outcome, provider-free no-wearable first-use path, setup, privacy/network boundary, known limitations, and verification evidence. +- Keep counsel correspondence and any personal/legal supporting documents outside the repository. Commit only approved public wording and a factual review status. +- Rerun the audit and full verification after all legal/copy changes and record final commit-specific results. +- Use an explicit Go/No-Go checklist: audited commit fixed, CI green, clean-install evidence green, legal gate complete, no unresolved blocker, draft release inspected, and maintainer approval recorded. Any failed item returns the candidate to the relevant earlier unit. + +**Patterns to follow:** +- Root legal-content guardrails and `LEGAL_TODO.md` for factual placeholders and review tracking. +- `CHANGELOG.md` for user-visible changes; `docs/releases/v2.2.0-verification.md` for non-sensitive evidence rather than raw logs. + +**Test scenarios:** +- **Consistency:** Business identity, contact, address, processor, retention, deletion, and support facts agree across all five legal pages and release documentation. +- **Boundary:** Every page and release note consistently describes local-first storage, configured LLM processing, no external training-data connectors, optional LangSmith, and localhost-only no-login use. +- **Deletion flow:** Counsel-facing wording matches a disposable-data reset observation and does not promise deletion from third-party processors the app cannot perform. +- **Regression:** Frontend lint/type-check/tests/build and full backend checks pass after legal copy changes. +- **Gate:** Release remains blocked while external review or any must-fix legal item is incomplete. + +**Verification:** +- Legal review is recorded, required corrections are applied, `LEGAL_TODO.md` reflects reality, final audit/CI evidence is green, and `docs/releases/v2.2.0.md` is ready to become the GitHub draft release body. + +- [ ] **Unit 7: Publish `v2.2.0`, then launch the evolution story** + +**Goal:** Publish an integrity-protected GitHub release and follow it with authentic, channel-specific Medium and Reddit communication. + +**Requirements:** R13–R16 + +**Dependencies:** Unit 6 complete and explicit maintainer confirmation immediately before external publication. + +**Files:** +- Verify: `docs/releases/v2.2.0.md` +- Modify after publication: `README.md` only if a stable release link or badge materially improves setup discovery +- Local ignored working material only: `.tmp/launch/` + +**Approach:** +- Enable GitHub release immutability if available, create the `v2.2.0` release as a draft against the verified commit, inspect all notes/assets, then ask for the final maintainer go-ahead before publishing. +- Attach no personal/generated assets; rely on reviewed repository screenshots and source archives unless a separately audited artifact is necessary. +- Verify the published tag/commit and immutable release state before sharing links. +- Draft the Medium article in the maintainer's voice around: Garmin experiment → realization that coaching cannot depend on one platform → declared-context coaching → season/28-day/coach experience → local-first architecture → provider-free boundary → honest limitations → how to run it. +- Prepare separate Reddit posts rather than mass-cross-posting identical copy. Lead with what was learned and invite critique; disclose authorship/affiliation and remain available for discussion. +- Recheck rules on posting day. Exclude `r/running`; use `r/selfhosted` only in its permitted current project surface; skip `r/opensource` unless the maintainer has rewritten and personally owns the post under its anti-AI-content rule; evaluate other communities individually. +- Publish Medium first after the release is stable, then Reddit over a measured cadence rather than simultaneous repeated promotion. +- Treat publication as irreversible. If a post-publication defect appears, annotate/deprecate the affected release as appropriate and publish a corrected patch release from a newly audited commit; never move or reuse the immutable `v2.2.0` tag. + +**Patterns to follow:** +- GitHub's draft-first immutable-release workflow. +- Reddit's sitewide authentic-participation guidance plus current per-community rules. +- The origin decision that athlete value leads and end-to-end architecture is supporting proof. + +**Test scenarios:** +- **Release integrity:** `v2.2.0` resolves to the exact audited commit and the release is reported immutable/verified when the GitHub feature is available. +- **Post-publication recovery:** A tabletop defect scenario results in a documented patch-release path without editing assets, moving the tag, or obscuring the affected version. +- **Link validation:** Release notes, README setup links, demo references, screenshots, legal/support links, and article links resolve without private or local-only URLs. +- **Message consistency:** GitHub, Medium, and each Reddit post lead with provider-free athlete value and make clear that v2.2.0 has no external training-data connector. +- **Policy compliance:** Each Reddit target is checked on posting day; prohibited communities are skipped and posts use the required flair/thread format. +- **Authenticity:** The maintainer reviews and rewrites promotional copy in their own voice before publication; generated drafts are not posted verbatim where prohibited. + +**Verification:** +- The release is public and verifiably tied to the audited commit; the Medium article is live; every Reddit submission is compliant, distinct, disclosed, and linked to the stable release. + +## System-Wide Impact + +- **Interaction graph:** Public discovery (`README`, metadata, social preview, demo) leads into local setup, first-run readiness, profile/race context, asynchronous planning, plan rendering, and provider-free coach chat. Optional providers branch only into richer evidence, daily sync, and weekly recap. +- **Error propagation:** Setup and generation failures must surface actionable local recovery without redirecting users to connectors. Audit, CI, legal, or smoke failures halt publication rather than becoming warnings. +- **State lifecycle risks:** Clean-install validation uses isolated disposable state. Normal restart must preserve it; cleanup must target only the disposable namespace. No release task may rewrite real owner IDs or plans. +- **API surface parity:** No public API or schema change is intended. Existing declared-only/connected evidence fields and versioned renderers remain stable. +- **Integration coverage:** Automated contracts prove readiness and claim boundaries; the clean-install smoke proves the real web/API/worker/Postgres/Redis/LLM path that mocks cannot. +- **Unchanged invariants:** Local owner mode, loopback binding, provider read-only scopes, schema-version routing, data-preservation rules, and agent-led coaching judgment remain unchanged. + +## Risks & Dependencies + +| Risk | Likelihood | Impact | Mitigation | +|---|---:|---:|---| +| Full Git history contains a real secret or private artifact | Medium | Critical | Run the redacted history audit first; rotate immediately; pause for approved remediation/history strategy. | +| “No wearable required” is read as “no data/context required” | Medium | High | Pair the phrase with declared profile, goals, races, availability, constraints, and one LLM key everywhere. | +| Provider-free plan quality is weak despite passing contracts | Medium | High | Require a real-LLM synthetic smoke and human acceptance before tagging. | +| Clean smoke touches existing athlete data | Low | Critical | Separate clone, unique Compose namespace, disposable volumes, no existing owner ID, pre/post isolation evidence. | +| External legal review delays publication | Medium | High | Start counsel review while technical polish runs; keep tag and promotion gated rather than weakening the requirement. | +| Linux-only verification surprises macOS/Windows users | Medium | Medium | State the supported/tested matrix explicitly and label other platforms unverified. +| Reddit promotion is removed or perceived as spam | Medium | Medium | Recheck rules, avoid `r/running`, use designated threads/flairs, disclose affiliation, customize posts, and engage authentically. | +| Immutable release is published with a bad asset or note | Low | High | Create and inspect a draft first; use only reviewed assets; require final maintainer confirmation. | +| A defect is discovered after immutable publication | Low | High | Preserve the tag, disclose the issue, prepare a newly audited patch release, and delay promotion until the corrected version is stable. | +| Dependency updates introduce unrelated churn | Low | Medium | Keep Dependabot updates isolated and require full CI per update. | + +## Phased Delivery + +### Phase 1 — Release candidate safety + +- Unit 1: audit gate +- Unit 2: dependency/CI/version readiness +- Begin external legal review with current facts + +### Phase 2 — Product truth and first-use proof + +- Unit 3: public message alignment +- Unit 4: provider-free first-use hardening +- Unit 5: isolated clean-install and real-LLM acceptance + +### Phase 3 — Publication gates + +- Unit 6: legal corrections, release notes, final audit and verification +- Maintainer go/no-go checkpoint +- Unit 7: immutable GitHub release + +### Phase 4 — Story distribution + +- Medium follow-up article +- Rule-compliant, paced Reddit submissions +- Capture real feedback as future roadmap input rather than expanding this release in flight + +## Success Metrics + +- A clean Ubuntu/Linux environment reaches a season roadmap, 28-day plan, and plan-aware coach response with no provider configured. +- Every public surface consistently leads with athlete value and “no wearable required.” +- Full audit, CI, clean-smoke, and legal gates have commit-specific passing evidence. +- `v2.2.0` is tied to the audited commit and published through a draft-first integrity workflow. +- Launch posts comply with current community rules and generate discussion without mass-posting identical promotional copy. + +## Documentation / Operational Notes + +- `agents_docs/roadmap/now.md` changes from cleanup to release completion; `agents_docs/roadmap/decision_log.md` records athlete-first/no-wearable positioning. +- `docs/local-first/release-checklist.md` becomes the reusable operational gate; `docs/releases/v2.2.0-verification.md` is release-specific evidence. +- Verification records contain command/result summaries, versions, commit IDs, and synthetic scenario outcomes — never secrets, raw model context, private data, or unreviewed screenshots. +- Publishing GitHub, Medium, or Reddit is an external mutation. Execution pauses for the maintainer immediately before each public action unless the maintainer explicitly performs it. +- Go/No-Go evidence is binary and commit-specific. A later code, dependency, legal-copy, fixture, or release-note change invalidates the relevant audit/build/smoke evidence and requires that gate to be rerun. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md](../brainstorms/2026-07-13-athlete-first-oss-release-requirements.md) +- Product/setup: `README.md`, `docs/local-first/setup.md`, `docs/local-first/ai-coaching-limitations.md` +- First-run flow: `api/services/dashboard_state.py`, `web/app/src/app/app/new/page.tsx` +- Evidence boundaries: `api/services/evidence_profile.py`, `tests/test_training_plan_prompt_contracts.py` +- Release/security policy: `SECURITY.md`, `.gitleaks.toml`, `.github/workflows/ci.yml` +- Versioning: `agents_docs/ops/version_governance.md`, `config/version_manifest.yaml` +- GitHub release management: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository +- GitHub immutable releases: https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases +- Gitleaks: https://github.com/gitleaks/gitleaks +- Gitleaks `v8.30.1`: https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1 +- Reddit spam policy: https://support.reddithelp.com/hc/en-us/articles/360043504051-Spam diff --git a/docs/plans/2026-07-19-001-refactor-head-coach-runtime-plan.md b/docs/plans/2026-07-19-001-refactor-head-coach-runtime-plan.md new file mode 100644 index 0000000..4c01da9 --- /dev/null +++ b/docs/plans/2026-07-19-001-refactor-head-coach-runtime-plan.md @@ -0,0 +1,663 @@ +--- +title: "refactor: Build the persistent Head Coach runtime" +type: refactor +status: active +date: 2026-07-19 +origin: docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md +deepened: 2026-07-19 +--- + +# refactor: Build the persistent Head Coach runtime + +## Overview + +Replace the provider-shaped mandatory analysis pipeline with one accountable Head Coach built on LangChain's current `create_agent` abstraction and LangGraph's durable runtime. Deliver the change incrementally: first establish evaluation and contracts, then ship a provider-free initial planning path beside the legacy graph, then converge coach chat, recap, daily adaptation, and replanning on the shared runtime. Deep Agents is evaluated separately and does not block the release path. + +The first release gate is deliberately narrower than the complete target: provider-free initial plan generation must become resumable, free of mandatory deep-reasoning formatter agents, safe, visually rich, and compatible with existing active-plan APIs and UI. Existing coach conversations, proposals, and schema-v2 plans stay readable throughout migration. + +## Problem Frame + +The current integrated workflow always runs three provider-oriented summarizers, three provider-oriented experts, synthesis, planners, and three LLM formatters. A real provider-free OpenAI run completed successfully but required 13 model calls, roughly 19 minutes, and at least 227,937 tracked tokens while its provider projections were empty. This contradicts the product's no-wearable-required promise and makes worker failure unnecessarily expensive. + +The repo already contains much of the desired ownership infrastructure: local active-plan records, coach threads and events, idempotent turn requests, proposal preview/accept/reject behavior, safety handling, plan version checks, cost records, SSE statuses, and versioned renderers. The plan extends those seams instead of introducing a parallel ownership system (see origin: `docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md`). + +## Requirements Trace + +- R1-R4: One durable Head Coach identity owns judgment, uses specialists only as consultations, and asks when material context is missing. +- R5-R7: PostgreSQL domain records remain canonical; checkpoint and optional provider state never compete with athlete and plan truth. +- R8-R11: Full coaching context, atomic tools, explicit mutation authority, semantic reasoning profiles, and research tools only when needed. +- R12-R15: Durable checkpoint/resume, interrupts, idempotency, product-level lifecycle events, and backward-compatible migration. +- R16-R17: Validated canonical artifacts carry rich semantic presentation intent; deterministic versioned components render it, with no mandatory target-state deep-reasoning formatter agents. +- R18-R20: Measurable quality gates, a non-blocking Deep Agents spike, and preserved coaching safety. + +## Scope Boundaries + +- Do not reintroduce a mandatory training-data provider or make a connector part of the release-critical path. +- Do not replace PostgreSQL domain models with LangGraph state, LangGraph Store, OpenAI response state, or virtual files. +- Do not build a general multi-agent platform, arbitrary shell execution, or an agent plugin system. +- Do not require LangGraph Cloud, LangSmith deployment, or any hosted runtime for the local OSS app. +- Do not destructively rewrite existing active plans, jobs, coach events, or memories. +- Do not remove the legacy pipeline until compatibility and evaluation gates pass. +- Do not add Deep Agents to production dependencies during the core migration. +- Do not expose the no-login app beyond loopback or add sensitive athlete context, credentials, or raw model reasoning to logs or public traces. + +## Context & Research + +### Relevant Code and Patterns + +- `services/ai/langgraph/workflows/planning_workflow.py` defines the current mandatory graph and creates a new `MemorySaver` for each invocation. +- `services/ai/langgraph/nodes/tool_calling_helper.py` is the shared hand-rolled loop used by full planning, coach turns, recap, and daily updates. +- `services/ai/coach/continuum_turn_agent.py` already presents a long-term coach identity and returns validated output, but manually binds tools and performs a separate formatting call. +- `api/services/coach_context.py` assembles local memory, plan identity, competitions, events, evidence limits, and UI context. +- `api/services/ongoing_tools.py` demonstrates an invocation-scoped async tool registry, caching, observability, and optional provider degradation. +- `api/services/coach_turn.py` provides idempotent requests, safety disclaimers, proposal creation, version-checked acceptance, event persistence, and trace/cost provenance. +- `api/services/coach_event_store.py` is the existing append-only Decision Ledger foundation and owns ordered thread events. +- `api/models/active_season_plan.py` and `api/models/active_weekly_plan.py` persist versioned JSONB artifacts without requiring a domain-table redesign for a new schema version. +- `worker/tasks.py` owns background job lifecycle, cancellation, progress, result sanitization, and active-plan upserts. +- `api/routers/analysis.py` exposes the existing job start/status/result/cancel contract; `api/routers/coach.py` already streams product statuses over SSE. +- `web/app/src/components/plan-viewer/versioned/plan-renderer.tsx` branches by schema version, and `web/app/src/components/markdown_snippet.tsx` already renders Markdown deterministically. +- `tests/test_coach_turn_idempotency.py`, `tests/test_coach_turn_safety.py`, `tests/test_coach_patch_apply.py`, and `tests/test_langgraph_planning_workflow.py` provide characterization patterns to preserve. + +### Institutional Learnings + +- No `docs/solutions/` knowledge base exists in this repository, so there are no project-specific solution records to carry forward. +- Root and scoped `AGENTS.md` files are authoritative: agents own coaching judgment; deterministic code owns validation, authorization, persistence, rate limits, and idempotency; async I/O and mocked external services are required. + +### External References + +- LangChain `create_agent` is the current standard agent abstraction and replaces `langgraph.prebuilt.create_react_agent`: https://docs.langchain.com/oss/python/releases/langchain-v1 +- Middleware supports dynamic prompts/tools/models, retries, call limits, summarization, and human-in-the-loop without a custom tool loop: https://docs.langchain.com/oss/python/langchain/middleware/overview +- LangGraph checkpoints enable fault recovery, interrupts, replay, and cross-invocation threads: https://docs.langchain.com/oss/python/langgraph/persistence +- Durable execution requires deterministic replay boundaries and idempotent side effects: https://docs.langchain.com/oss/python/langgraph/durable-execution +- Runtime Context injects user IDs, database dependencies, Store access, and stream writers without serializing them into graph state: https://docs.langchain.com/oss/python/langchain/runtime +- OpenAI Responses supports reasoning profiles and native structured output, but provider-side response state is not used as canonical memory: https://docs.langchain.com/oss/python/integrations/chat/openai +- Deep Agents is an opinionated harness for planning, context files, skills, and subagents; simpler agents should continue to use `create_agent`: https://docs.langchain.com/oss/python/deepagents/overview + +## Key Technical Decisions + +| Decision | Chosen direction | Rationale | +|---|---|---| +| Agent abstraction | Shared Head Coach factory using LangChain `create_agent` | Removes the custom standard tool loop while retaining LangGraph composition and middleware hooks. | +| Orchestration | Small LangGraph spine around the agent | Deterministic load, review, interrupt, commit, and lifecycle boundaries remain visible and checkpointable; coaching decisions stay with the model. | +| Domain ownership | Existing API services and PostgreSQL records | Coach threads, events, proposals, active plans, jobs, and usage records already enforce core ownership rules. | +| Execution persistence | Persistent PostgreSQL checkpointer with stable job/thread IDs | Resumes completed graph steps across worker restarts; serialized state contains values and IDs only, never sessions or tool objects. | +| Long-term memory | Existing athlete model, memory summary, and event history | Avoids a duplicate LangGraph Store during the first migration. Store adoption requires a later explicit use case. | +| Output contract | Schema-v3 canonical Markdown artifacts, typed calendar/session fields, and semantic presentation blocks | Preserves expressive coaching content and LLM-authored information hierarchy without making raw HTML/CSS part of the model contract. | +| UI composition | Head Coach emits presentation intent by default; a constrained low-cost UI Composer is optional | Rich cards, callouts, tables, checklists, timelines, and disclosures remain plan-specific while separate deep reasoning is avoided. | +| UI migration | Keep v2 renderers; add deterministic v3 React components using existing Markdown support | Existing plans remain readable; React owns accessibility, responsiveness, sanitization, and styling. | +| Invalid model output | Bounded LLM self-repair followed by visible failure | Validation remains deterministic, but rules never invent replacement coaching or presentation content; failed repair commits nothing. | +| Reasoning | Entry-point run profiles with explicit escalation | Initial planning and material replanning get deep reasoning; chat, memory, and rendering do not inherit maximum reasoning accidentally. | +| Specialists | Read-only consultation tools, dynamically exposed | The Head Coach chooses consultation based on the actual task; specialists cannot commit domain changes. | +| Deep Agents | Isolated benchmark spike after core contracts exist | Tests its concrete value without making a fast-moving harness or preview async subagents release-critical. | + +## Open Questions + +### Resolved During Planning + +- **Canonical state boundary:** Existing PostgreSQL domain tables remain canonical. LangGraph checkpointer tables contain execution progress only; no LangGraph Store is introduced in the first migration. +- **Checkpoint identity:** Analysis job ID is the stable thread identity for initial/recalibration jobs. Conversational executions encode coach thread, scope, and run ID in the stable `thread_id`; LangGraph's `checkpoint_ns` remains reserved for its internal subgraph namespace semantics. Invocation-scoped dependencies are re-injected on resume. +- **Checkpoint retention:** Checkpoints may contain private working context required for resume. Runs use distinct execution thread IDs, are removed after a bounded terminal-run retention period, and are included in local-owner deletion. They are never treated as an audit ledger. +- **Artifact boundary:** New outputs use schema-v3 domain artifacts with Markdown narrative, typed calendar fields, and semantic presentation blocks. Existing schema-v2 JSON remains supported and continues through its current proposal operations; schema-v3 proposal parity must land before any v3 plan becomes active. +- **Reasoning routing:** Selection follows explicit run semantics, not message length, provider presence, or deterministic proxy scores. The Head Coach may invoke a bounded deep-consultation capability when a lower-effort entry point encounters a genuinely consequential decision. +- **Initial mutation authority:** A Generate command authorizes one version-checked initial commit. Subsequent material changes continue through proposal acceptance. +- **Release gate:** The core release is not blocked on weekly recap convergence or Deep Agents. It is blocked on the new provider-free initial plan path, safety, compatibility, rich semantic rendering, and durable retry. + +### Deferred to Implementation + +- Exact schema-v3 field names, Markdown section granularity, and semantic component catalog should be finalized while writing contract tests against representative stored plans. +- Whether native provider structured output or tool-based structured output is more reliable for the complete v3 plan schema must be decided from mocked contract tests and the opt-in real-model evaluation. +- The checkpoint package's table bootstrap must be reconciled with the repo's Alembic policy before execution. This is a persistent schema mutation and requires explicit user approval before implementation touches the database setup. +- Exact prompt composition may be simplified after the baseline eval shows which existing instructions materially affect quality. + +## User and Runtime Flows + +| Flow | Entry | Important branches | Terminal states | +|---|---|---|---| +| Initial plan | Explicit Generate command | sufficient context; clarification needed; cancellation; model/tool failure | committed; awaiting input; cancelled; failed | +| Resume | Answer to a persisted clarification or worker retry | stale/cancelled job; valid checkpoint; already committed | resumed and committed; conflict; no-op | +| Coach turn | Coach inbox message or plan-day context | answer only; consultation; plan proposal; health concern | message; pending proposal; safe refusal/escalation; error | +| Proposal | Accept, edit, or reject | active-plan version unchanged or stale; duplicate request | committed once; rejected; version conflict; idempotent replay | +| Legacy read | Open existing plan/calendar | schema v2 or v3 | appropriate versioned renderer; explicit unsupported-version error | +| Optional evidence | Future connected-source capability | tool available; unavailable; failing | evidence used with provenance; provider-free continuation | + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + Entry[API, worker, recap, or coach entry point] --> Profile[Semantic run profile] + Profile --> Context[Load full local Coach Brief] + Context --> Runtime[Head Coach create_agent runtime] + Runtime --> ReadTools[Read-only domain tools] + Runtime --> Specialist[Optional specialist consultation] + ReadTools --> Runtime + Specialist --> Runtime + Runtime --> Decision{Result kind} + Decision -->|clarification| Interrupt[Durable interrupt] + Interrupt --> Runtime + Decision -->|answer| Event[Append coach event] + Decision -->|initial plan| Validate[Validate canonical artifacts] + Decision -->|active-plan change| Proposal[Persist proposal] + Proposal --> Approval[Approve, edit, or reject] + Approval --> Validate + Validate --> Commit[Idempotent deterministic commit] + Commit --> Domain[(Canonical PostgreSQL domain state)] + Checkpoint[(LangGraph checkpoints)] -. execution progress .-> Runtime + Domain --> Context +``` + +The dependency structure is: + +```mermaid +flowchart TB + U1[1. Baseline and eval gates] --> U2[2. Contracts, context, and run profiles] + U2 --> U3[3. Durable runtime foundation] + U2 --> U4[4. Canonical v3 artifacts] + U3 --> U5[5. Initial planning vertical slice] + U4 --> U5 + U5 --> U6[6. Head Coach convergence] + U5 --> U7[7. Legacy retirement and docs] + U6 --> U7 + U1 --> U8[8. Deep Agents spike] + U2 --> U8 +``` + +## Implementation Units + +- [x] **Unit 1: Freeze the baseline and add architecture evaluation gates** + +**Goal:** Turn the successful provider-free run and existing coach behavior into a repeatable regression baseline before changing orchestration. + +**Requirements:** R15, R18, R20 + +**Dependencies:** None + +**Files:** +- Create: `tests/fixtures/head_coach_eval_cases.json` +- Create: `tests/test_head_coach_eval_contracts.py` +- Create: `services/ai/evals/head_coach_eval.py` +- Modify: `tests/test_langgraph_planning_workflow.py` +- Modify: `tests/test_provider_free_release_contract.py` +- Modify: `tests/test_coach_turn_safety.py` +- Modify: `tests/test_cost_tracking_integration.py` + +**Approach:** +- Capture representative provider-free cases: sparse beginner context, experienced athlete constraints, conflicting availability, missed week, stale plan, pain/illness, optional evidence unavailable, and material plan change. +- Separate deterministic release gates from opt-in real-model quality experiments. Normal tests mock model and HTTP behavior; real OpenAI evaluation is explicitly invoked and never runs in the default suite. +- Record the current graph trajectory and the July 19 run's calls, latency, tokens, cost, and outputs as baseline metadata without committing private athlete content or generated artifacts. +- Gate hard invariants at 100%: schema validity, declared-constraint preservation, no unauthorized mutation, no provider call in provider-free mode, safety response, and idempotent commit/resume. +- Treat quality as non-inferiority: the new path must have no safety loss and should be rated equal or better in at least 80% of blinded pairwise curated cases before legacy retirement. Require zero dedicated deep-reasoning formatter calls, measure presentation quality explicitly, and target at least 50% fewer mandatory model calls than the 13-call baseline; performance targets cannot justify context stripping, generic UI, or heuristic coaching. + +**Execution note:** Add characterization coverage before changing the legacy workflow. + +**Patterns to follow:** +- `tests/fixtures/coach_quality_eval_cases.json` +- `services/ai/coach/quality_eval.py` +- `tests/test_coach_quality_eval.py` + +**Test scenarios:** +- Happy path: a complete declared-context case produces valid season and execution-plan artifacts and preserves all availability constraints. +- Edge case: no provider observations are present and the expected trajectory contains no provider summarizer, provider expert, or provider tool call. +- Safety: pain or illness input triggers the existing health boundary and never schedules immediate high intensity. +- Mutation: a simulated model result requesting an unauthorized active-plan write is rejected before persistence. +- Evaluation: baseline and candidate outputs can be compared without including athlete PII or requiring a default-suite network call. + +**Verification:** +- The suite establishes reproducible behavioral, trajectory, cost, and safety gates against which every later unit can be measured. + +- [x] **Unit 2: Define shared Head Coach contracts, context, tools, and reasoning profiles** + +**Goal:** Create one reusable Head Coach identity and typed runtime boundary shared by planning and conversational entry points. + +**Requirements:** R1-R11, R16, R20 + +**Dependencies:** Unit 1 + +**Files:** +- Create: `services/ai/head_coach/__init__.py` +- Create: `services/ai/head_coach/schemas.py` +- Create: `services/ai/head_coach/prompts.py` +- Create: `services/ai/head_coach/run_profiles.py` +- Create: `services/ai/head_coach/runtime_context.py` +- Create: `services/ai/head_coach/tool_policy.py` +- Modify: `services/ai/ai_settings.py` +- Modify: `services/ai/model_config.py` +- Modify: `api/services/coach_context.py` +- Modify: `api/services/ongoing_tools.py` +- Test: `tests/test_head_coach_contracts.py` +- Test: `tests/test_head_coach_tool_policy.py` +- Test: `tests/test_model_config.py` + +**Approach:** +- Define a small set of semantic run profiles such as initial planning, material replanning, coach turn, weekly recap, daily adaptation, memory extraction, and specialist consultation. +- Keep per-entry output schemas focused rather than creating one universal mega-response. Share provenance, assumptions, evidence limits, safety, and mutation-intent types. +- Consolidate durable coaching principles from current planner and continuum prompts into one Head Coach identity, while task-specific instructions remain profile-scoped. +- Pass serializable IDs and values in graph state. Inject database/session factories, user identity, capability registry, status writer, and configuration through Runtime Context. +- Convert existing local reads into atomic, typed tools. Dynamically expose optional tools based on actual capabilities and authorization; absence of provider capability results in no provider tool, not an empty analysis stage. +- Use model configuration from `services.ai.model_config`. Deep planning/replanning profiles use high or xhigh effort; coach turns use standard/medium by default; an optional UI Composer uses none/low or at most medium; memory and deterministic rendering do not inherit deep reasoning. + +**Patterns to follow:** +- `services/ai/coach/continuum_turn_agent.py` for coaching identity and safety language. +- `api/services/ongoing_tools.py` for async tool construction, request caching, and observability. +- `services/ai/coach/schemas.py` for focused Pydantic contracts. + +**Test scenarios:** +- Happy path: initial-planning profile receives complete local context, planning tools, and standard reasoning; xhigh is reserved for bounded material replanning and research. +- Happy path: coach-turn profile receives conversation and active-plan tools but cannot directly commit a plan. +- Edge case: no providers are configured and no provider-named tool is exposed. +- Edge case: a future provider is available but the run profile does not need external evidence, so its tool remains unavailable to the model. +- Safety: specialist and research outputs retain provenance and cannot be promoted to user-declared facts. +- Error path: an invalid or unsupported run profile fails before a model call. + +**Verification:** +- All Head Coach entry points can be expressed through typed profiles without hardcoded model names, provider assumptions, or write-capable specialist tools. + +- [x] **Unit 3: Add the durable local LangGraph runtime foundation** + +**Goal:** Make long-running Head Coach work checkpointable, resumable, observable, and safe under retry. + +**Requirements:** R5, R6, R12-R14 + +**Dependencies:** Unit 2; explicit approval before any checkpoint-table schema mutation + +**Files:** +- Modify: `pixi.toml` +- Modify: `pixi.lock` +- Create: `api/migrations/versions/002_add_langgraph_checkpoint_tables.py` +- Create: `services/ai/head_coach/checkpointing.py` +- Create: `services/ai/head_coach/graph.py` +- Create: `services/ai/head_coach/middleware.py` +- Modify: `api/config.py` +- Modify: `.env.example` +- Modify: `worker/celery_app.py` +- Modify: `worker/tasks.py` +- Modify: `api/services/account_deletion.py` +- Test: `tests/test_head_coach_runtime.py` +- Test: `tests/test_head_coach_checkpointing.py` +- Test: `tests/test_head_coach_interrupts.py` +- Test: `tests/test_head_coach_checkpoint_retention.py` + +**Approach:** +- Add the official PostgreSQL checkpoint package and one process-safe checkpointer factory; never instantiate a new in-memory saver per production invocation. +- Pin and audit the official checkpointer version, then integrate its required unqualified checkpoint table set through an Alembic migration. The current Python package does not expose a reliable arbitrary PostgreSQL-schema option, so do not depend on a `search_path` trick. Document how future package migrations are reviewed and reflected in Alembic before upgrading. +- Use stable, owner-scoped thread IDs for planning jobs and distinct coach execution threads. Keep LangGraph's root `checkpoint_ns` empty/reserved because the runtime uses it internally for subgraphs. Keep model inputs, messages, artifact drafts, interrupt payloads, and tool results serializable. +- Wrap the shared `create_agent` graph with deterministic load/review/interrupt/commit nodes only where the product lifecycle requires them. +- Use built-in middleware selectively for model/tool call limits, retry policy, dynamic prompts/tools, and lifecycle events. Do not add summarization until a measured context-window need exists, and do not use it to remove required coaching context. +- Ensure side effects occur in idempotent tasks or post-agent commit services. A resumed node must not append duplicate events, consume quota twice, or write the same artifact twice. +- Add a stable PostgreSQL advisory-lock claim derived from run identity so overlapping Celery retry delivery and manual resume cannot execute the same checkpoint concurrently. Release it on pause/termination; cancellation and terminal domain commit always win when a later delivery acquires the lock. +- Define terminal cleanup and local-owner deletion for checkpoint threads. Completed, failed, and cancelled runs remain available only for the bounded debugging window; durable Coach Events and active plans retain the product history. +- Emit stable product lifecycle events independent of graph node names. Trace only sanitized metadata, costs, tool names, and artifact IDs; exclude credentials, raw private context, and raw reasoning. +- Keep an in-memory checkpointer injectable for deterministic tests. + +**Patterns to follow:** +- `api/services/coach_turn.py` for idempotency claims and duplicate-safe outcomes. +- `worker/tasks.py` for stable job IDs, cancellation, retry, and progress callbacks. +- `services/ai/langgraph/config/langsmith_config.py` for optional local tracing configuration. + +**Test scenarios:** +- Happy path: a graph completes with a stable execution thread ID and records its root checkpoints under LangGraph's reserved root namespace. +- Failure recovery: execution fails after a completed mocked model/tool step, resumes with the same job ID, and does not invoke that completed step again. +- Interrupt: a clarification payload persists, survives graph recreation, accepts one resume value, and continues to the next state. +- Idempotency: duplicate resume and duplicate worker delivery produce one domain commit and one quota/event effect. +- Concurrency: two workers claiming the same run cannot advance the checkpoint concurrently; an expired claim can be recovered. +- Cancellation: a cancelled job cannot be silently resumed by an automatic retry. +- Retention: terminal-run cleanup removes expired checkpoints while leaving canonical plans and Coach Events intact; local-owner deletion removes that owner's checkpoint state. +- Security: checkpoint and trace payloads contain no API keys, database sessions, provider credentials, or raw reasoning blocks. +- Error path: checkpointer unavailability fails the long-running job explicitly without falling back to an unresumable production run. + +**Verification:** +- A process restart between completed graph steps can recover the same run, and checkpoint persistence remains clearly separate from domain ownership. + +- [x] **Unit 4: Introduce canonical schema-v3 artifacts and rich semantic UI composition** + +**Goal:** Preserve rich LLM-authored information hierarchy and coaching components while removing mandatory deep-reasoning formatting passes, raw model-authored HTML, and presentation ownership from React. + +**Requirements:** R5, R15-R17 + +**Dependencies:** Unit 2 + +**Files:** +- Create: `services/ai/head_coach/artifacts.py` +- Create: `services/ai/head_coach/artifact_rendering.py` +- Create: `services/ai/head_coach/ui_composer.py` +- Modify: `services/ai/langgraph/schemas/ui_blocks.py` +- Modify: `api/services/active_plans.py` +- Modify: `services/ai/coach/schemas.py` +- Modify: `services/ai/coach/patch_apply.py` +- Modify: `api/services/coach_patch_ops.py` +- Modify: `web/app/src/components/plan-viewer/types.ts` +- Create: `web/app/src/components/plan-viewer/versioned/season-plan-view-v3.tsx` +- Create: `web/app/src/components/plan-viewer/versioned/weekly-plan-view-v3.tsx` +- Modify: `web/app/src/components/plan-viewer/versioned/plan-renderer.tsx` +- Create: `web/app/src/lib/demo/fixtures/v3/README.md` +- Test: `tests/test_head_coach_artifacts.py` +- Test: `tests/test_head_coach_ui_composer.py` +- Test: `tests/test_active_plans_v3.py` +- Test: `tests/test_coach_patch_apply.py` +- Test: `tests/test_api_coach_turn_routes.py` +- Test: `web/app/src/tests/plan/schema-v3-rendering.tsx` + +**Approach:** +- Define immutable Pydantic value objects for Season Strategy and 28-day Execution Plan with stable IDs, Markdown narrative fields, typed dates/session metadata, semantic presentation blocks, assumptions, risks, evidence provenance, unresolved questions, and a Decision Ledger entry. +- Define a bounded component catalog for plan-specific presentation, including workout, interval table, callout, checklist, fueling, recovery, notes, data table, phase timeline, and disclosure intent. The model chooses hierarchy, grouping, emphasis, tone, and content; React owns the concrete component, CSS, responsive behavior, accessibility, and sanitization. +- Store v3 canonical artifacts in existing JSONB plan payloads; no destructive rewrite of v2 records is required. +- Render Markdown through the existing `MarkdownSnippet` path and map semantic blocks to deterministic React components. Never accept LLM-authored arbitrary HTML or CSS in v3. +- Make direct Head Coach presentation intent the standard path. Evaluate a constrained UI Composer only when it materially improves visual hierarchy: it receives an immutable artifact, may reorganize or annotate presentation blocks, uses none/low or at most medium reasoning, and must preserve a semantic hash of all coaching decisions and typed session data. +- On invalid Head Coach or UI Composer output, return structured validation errors and the rejected output to the responsible model for a bounded repair attempt. Do not synthesize default blocks, silently drop invalid sections, coerce unsupported component types, or replace the result with generic Markdown. If the repair budget is exhausted, fail the run visibly, record sanitized diagnostics, and leave the prior canonical plan untouched. +- Keep v2 renderers unchanged. Branch strictly on `schema_version`, add sanitized synthetic v3 fixtures, and return an explicit unsupported-version state rather than guessing. +- Preserve current calendar identity and proposal requirements: stable day/week IDs, dates, completion state, duration, intensity, and version. +- Extend plan patch validation, preview, and application to dispatch on schema version before any v3 plan can become active. V2 operations remain unchanged; v3 operations target typed session fields and Markdown blocks without introducing arbitrary HTML. + +**Execution note:** Start with contract fixtures that both Python validation and the TypeScript renderer consume conceptually; avoid changing v2 behavior while adding v3. + +**Patterns to follow:** +- `services/ai/langgraph/schemas/ui_blocks.py` for versioned validated data. +- `web/app/src/components/plan-viewer/versioned/plan-renderer.tsx` for schema dispatch. +- `web/app/src/components/markdown_snippet.tsx` for safe deterministic Markdown rendering. + +**Test scenarios:** +- Happy path: representative v3 season and weekly artifacts validate and render all narrative and calendar fields. +- Compatibility: a stored v2 plan renders through the unchanged v2 path after v3 is introduced. +- Edge case: Markdown contains links, tables, lists, and line breaks and renders without executable HTML or scripts. +- Composition: the Head Coach can select different semantic components for different plan content instead of producing a uniform Markdown wall. +- Composer safety: an attempt to change a workout prescription, date, duration, intensity, assumption, or risk returns precise validation feedback for LLM repair; exhausted repair fails without a commit. +- Repair: malformed component intent is corrected by the responsible mocked model after receiving field-level errors and then validates successfully. +- Repair exhaustion: repeated invalid output produces an explicit failed result, preserves the previous active plan, and generates no rule-authored replacement blocks. +- Edge case: unknown schema version yields an explicit safe unsupported state. +- Validation: duplicate IDs, out-of-range dates, or execution days outside the declared block fail before persistence. +- Mutation: completion state and plan version survive deterministic re-rendering and round trips. +- Proposal parity: a v3 day change previews, rejects stale versions, applies once after acceptance, and leaves the v2 proposal path unchanged. + +**Verification:** +- New artifact fixtures render as rich plan-specific components without a dedicated deep-reasoning formatter, while existing v2 fixtures and calendar tests continue to pass. +- Completed 2026-07-19: immutable v3 Season Strategy and exact 28-day Execution artifacts, semantic-hash-preserving optional composition, bounded responsible-model repair, strict kind-specific schema dispatch, typed v3 mutations/proposals, and rich React renderers shipped with sanitized synthetic fixtures. Full verification passed: Ruff, Mypy (271 source files), Pytest (538 passed, 3 skipped), frontend fixtures/tests/type-check/lint, version governance, and Next.js production build. + +- [x] **Unit 5: Ship the provider-free initial planning vertical slice** + +**Goal:** Route initial Generate requests through the durable Head Coach, commit schema-v3 artifacts once, and preserve current job/API behavior. + +**Requirements:** R1-R18, R20 + +**Dependencies:** Units 3 and 4 + +**Files:** +- Create: `services/ai/head_coach/initial_planning.py` +- Create: `web/app/src/components/plan-viewer/plan-generation-interrupt.tsx` +- Modify: `worker/tasks.py` +- Modify: `api/routers/analysis.py` +- Modify: `api/services/status_messages.py` +- Modify: `api/services/full_run_policy.py` +- Modify: `web/app/src/app/actions/plan.ts` +- Modify: `web/app/src/app/app/plan/page.tsx` +- Test: `tests/test_head_coach_initial_planning.py` +- Test: `tests/test_worker_head_coach_plan.py` +- Test: `tests/test_api_head_coach_resume.py` +- Test: `tests/test_worker_analysis_results.py` +- Test: `web/app/src/tests/onboarding/no-provider-first-run.ts` +- Test: `web/app/src/tests/plan/plan-generation-interrupt.tsx` + +**Approach:** +- Add an explicit workflow-version selector to the job config so new initial drafts use the Head Coach path while existing jobs and rollback can still invoke legacy behavior during the migration window. +- Assemble the full local Coach Brief from profile, competitions, declared history, constraints, current date/calendar, prior plans when relevant, and existing memory. Do not construct empty metrics/physiology/activity projections. +- Let the agent produce v3 Season Strategy and Execution Plan through validated structured output. A deterministic review node checks schema, constraints, safety, and commit authority; it does not replace coaching judgment with score thresholds. +- Use the existing job ID as checkpoint thread ID and idempotency key. Commit both active artifacts and the Decision Ledger record in one deterministic transaction or leave the prior active state unchanged. +- Acquire the AnalysisJob row and the per-user generation guard before commit. The application transaction writes season plan, weekly plan, Decision Ledger event, usage/cost linkage, and terminal job result together. Checkpoint completion is outside that transaction and may be retried; a retry observes the terminal job/source identity and returns the already-committed result instead of writing again. +- Add `awaiting_input` as a job lifecycle state for durable clarifications and a resume operation that supplies the answer to the existing checkpoint. Cancellation remains terminal. +- Treat `awaiting_input` as a successful pause, not a Celery failure: persist a sanitized interrupt envelope in the job projection, release the worker and execution lease, and enqueue the same job ID only after an authorized resume request. +- On the plan page, replace progress with one inline Coach question card containing the question, a text response, Continue, and Cancel. Refreshing the page reconstructs the same card from job status; Continue is keyboard/submission accessible, disables while submitting, and uses an idempotency key so double submission cannot enqueue two resumes. Resume authorization reuses job ownership checks. +- Translate runtime events to product copy such as understanding context, designing strategy, building the block, reviewing constraints, awaiting input, and saving the plan. +- Keep result serialization compatible for current clients during rollout; new clients branch on artifact schema version. + +**Execution note:** Build the new path beside the legacy workflow and keep the switch reversible until the evaluation gate passes. + +**Patterns to follow:** +- `worker/tasks.py` job lifecycle and `_upsert_active_results` transaction boundaries. +- `api/routers/analysis.py` start/status/result/cancel ownership checks. +- `api/services/coach_turn.py` idempotency and proposal conflict behavior. + +**Test scenarios:** +- Happy path: a provider-free Generate request reaches the Head Coach path, emits product statuses, and commits one v3 season and weekly plan. +- Edge case: sparse but sufficient declared context produces explicit assumptions without fabricating device metrics. +- Clarification: materially insufficient availability causes `awaiting_input`; a valid answer resumes the same job and commits once. +- Clarification UI: refresh preserves the pending question, empty answers stay local with an actionable validation state, double submit enqueues once, and Cancel reaches the terminal cancelled state. +- Failure recovery: a worker crash after strategy completion resumes without repeating the completed call. +- Cancellation: cancelling before resume leaves current active plans unchanged and prevents retry resurrection. +- Concurrency: two Generate requests for the same owner cannot interleave into a mixed season/weekly active state. +- Commit recovery: a crash after the domain transaction but before checkpoint finalization returns the already-committed artifacts on retry and emits no duplicate Decision Ledger event. +- Compatibility: a legacy in-flight job still finishes through the legacy path and remains readable. +- Safety: injury/illness context cannot result in an unsafe immediate-intensity plan and is surfaced in assumptions/risks. +- Performance: provider-free trajectory has zero mandatory provider-specialist and zero dedicated deep-reasoning formatter calls, while any optional UI Composer is separately attributed and the total path meets the agreed call-reduction gate. + +**Verification:** +- The local first-run journey works end to end through the new path, survives a forced restart, and can be rolled back without data loss. + +**Completed 2026-07-19:** New initial drafts now route through the durable Head Coach selector, pause and resume through an owned `awaiting_input` job state, and atomically publish schema-v3 Season Strategy and 28-day Execution artifacts with Decision Ledger, usage, and cost linkage. The source-of-ownership generation path was split into Strategy and Execution stages after live OpenAI gates showed the monolithic xhigh and medium calls exceeding ten minutes. GPT-5.6 Sol in standard (`medium`) reasoning produced a fully validated two-stage artifact pair in 233 seconds. PostgreSQL integration tests cover restart-safe checkpoints, exclusive claims, atomic publication, and duplicate-commit suppression; frontend tests cover the durable clarification card. + +- [x] **Unit 6: Converge coach chat, proposals, recap, and daily adaptation on the Head Coach** + +**Goal:** Present one coherent coach identity and runtime across ongoing interactions without weakening existing proposal, safety, quota, or event behavior. + +**Requirements:** R1-R14, R16, R20 + +**Dependencies:** Unit 5 + +**Files:** +- Modify: `services/ai/coach/continuum_turn_agent.py` +- Modify: `services/ai/recap/weekly_recap_agent.py` +- Modify: `services/ai/daily/daily_update_agent.py` +- Modify: `services/ai/coach/plan_modifier_agent.py` +- Modify: `api/services/coach_turn.py` +- Modify: `api/services/recap.py` +- Modify: `api/services/daily_update_runs.py` +- Modify: `api/routers/coach.py` +- Modify: `api/routers/weekly_recap.py` +- Test: `tests/test_continuum_turn_agent.py` +- Test: `tests/test_api_coach_turn_routes.py` +- Test: `tests/test_coach_turn_idempotency.py` +- Test: `tests/test_coach_turn_safety.py` +- Test: `tests/test_weekly_recap_agent.py` +- Test: `tests/test_daily_update_agent.py` +- Test: `web/app/src/tests/coach/coach-turn-sse.ts` + +**Approach:** +- Migrate one entry point at a time to the shared factory, beginning with coach chat because it already has strong ownership and contract tests, then recap and daily adaptation. +- Preserve the existing API service as the mutation boundary: the model returns an answer or proposal intent; deterministic code validates patch operations, previews the versioned plan, and commits only after acceptance. +- Replace custom tool-loop traces with standard agent/tool events mapped onto the existing coach event projection and cost provenance. +- Build provider-free recap from plan execution, completion state, athlete responses, calendar history, and conversation context. Optional provider evidence remains an additive future capability. +- Use durable interrupts for clarification or sensitive mutation approval where the existing proposal flow is not sufficient; do not force a second approval layer onto already-versioned proposal acceptance. + +**Patterns to follow:** +- Existing `CoachProposal`, plan patch operations, thread event ordering, and request idempotency. +- Existing SSE `status`, `result`, `error`, and `done` envelope. + +**Test scenarios:** +- Chat answer: a context question produces one coach message and no proposal or plan mutation. +- Proposal: a material change produces a preview, waits for acceptance, and commits against the expected plan version. +- Conflict: the active plan changes before acceptance and the proposal is rejected with the existing version conflict. +- Duplicate request: replaying the same idempotency key returns the prior response without another model call, quota charge, or event. +- Provider-free recap: completed/missed calendar sessions and athlete feedback produce a useful recap without activity-provider data. +- Safety: pain/illness and pharmaceutical requests preserve current disclaimer/refusal behavior. +- Streaming: standard agent events map to stable product status messages without exposing internal node names or reasoning. + +**Verification:** +- Ongoing surfaces share the Head Coach prompt/runtime and retain all existing proposal, quota, provenance, event, and safety contracts. + +**Completed 2026-07-19:** Coach chat, weekly recap, and daily adaptation now use the shared `create_agent` Head Coach factory with explicit semantic run profiles, bounded structured-output repair, provider-free local tools, and optional connected evidence. Recap and daily narratives emit schema-v3 semantic blocks, while v1/v3 proposal preview and acceptance remain deterministic service-owned boundaries. The coach UI renders semantic recap content and schema-v3 before/after proposal diffs without model-authored HTML. Full verification passed: Ruff, Mypy (278 source files), Pytest (552 passed, 4 skipped), frontend tests/type-check/lint, and the Next.js production build. + +- [x] **Unit 7: Retire the mandatory legacy graph and align architecture documentation** + +**Goal:** Remove superseded provider-shaped stages and mandatory deep-reasoning formatter agents only after the new rich semantic path passes release gates. + +**Requirements:** R7, R15, R17, R18, R20 + +**Dependencies:** Units 5 and 6; evaluation gate passed + +**Files:** +- Modify or remove superseded modules under: `services/ai/langgraph/workflows/` +- Modify or remove superseded modules under: `services/ai/langgraph/nodes/` +- Modify: `services/ai/langgraph/utils/workflow_cost_tracker.py` +- Modify: `api/services/status_messages.py` +- Modify: `agents_docs/architecture/ai_ui_contract.md` +- Modify: `agents_docs/roadmap/now.md` +- Modify: `agents_docs/roadmap/roadmap.md` +- Modify: `agents_docs/roadmap/decision_log.md` +- Modify: `services/ai/.agents/skills/langgraph-workflows/SKILL.md` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Test: `tests/test_no_legacy_agent_path.py` +- Modify: `tests/test_status_messages.py` +- Modify: `tests/test_provider_free_release_contract.py` + +**Approach:** +- Delete only nodes and helpers with no remaining runtime consumer. Keep compatibility schemas/renderers needed to read historical v2 artifacts. +- Remove metrics/physiology/activity mandatory fan-out, master-orchestrator loops, and dedicated deep-reasoning analysis/season/weekly formatter calls from the default path. Retain the semantic component catalog, optional constrained UI Composer, and deterministic React rendering. +- Update the local LangGraph skill to recommend `langchain.agents.create_agent` rather than the deprecated prebuilt ReAct helper and to document the new source-of-truth boundaries. +- Record the durable decision: one Head Coach, database-owned artifacts, checkpoint-owned execution progress, on-demand specialists, and evidence-gated Deep Agents. +- Preserve cost/latency observability using semantic run profiles and tool events instead of legacy node-name accounting. + +**Execution note:** Run a reference search before every deletion. The repository owner explicitly declined a backward-compatible generation switch before this unit, so all new and repeat generation now use the Head Coach; historical artifact renderers remain versioned and readable. + +**Patterns to follow:** +- Versioned UI renderers remain additive; old stored artifacts are never rewritten merely to remove runtime code. +- Root release audit and secret/PII constraints remain mandatory. + +**Test scenarios:** +- Default path: no production entry point executes mandatory provider summarizers, experts, or dedicated deep-reasoning formatter nodes; rich semantic component rendering remains covered. +- Compatibility: schema-v2 stored plans still render and can be read after legacy runtime deletion. +- Observability: cost, token, latency, lifecycle, and tool metrics remain attributed to semantic Head Coach profiles. +- Security: release audit finds no private eval data, checkpoints, traces, or generated personal artifacts tracked in git. + +**Verification:** +- The repo documentation and runtime describe the same architecture, all release gates pass, and no dead legacy path remains accidentally callable. + +**Completed 2026-07-19:** Every Generate and refresh run now routes to the durable Head Coach; the worker has no legacy fallback. The provider-shaped summarizer/expert/orchestrator graph, manual tool loop, dedicated formatter agents, orphaned signal digest, unsafe plotting stack, node-name cost tracker, legacy seeding CLI, and obsolete model roles were removed after reference checks. Memory extraction moved to the shared low-reasoning profile. Product progress now exposes one semantic lifecycle, and the primary plan/job pages dispatch schema-v3 artifacts through the real versioned renderer. Architecture, roadmap, decision log, contributor skill, README, and changelog now describe the same ownership model. Verification passed: Ruff, Mypy (224 source files), Pytest (442 passed, 4 skipped), frontend tests/type-check/lint, Next.js production build, version governance, and `git diff --check`. + +- [ ] **Unit 8: Evaluate Deep Agents for one bounded research specialist** + +**Goal:** Determine whether Deep Agents measurably improves a real long-horizon specialist task without changing the production Head Coach architecture prematurely. + +**Requirements:** R3, R11, R18, R19 + +**Dependencies:** Units 1 and 2; does not block Units 3-7 + +**Files:** +- Create: `experiments/deep_agents_event_research/README.md` +- Create: `experiments/deep_agents_event_research/evaluate.py` +- Create: `tests/fixtures/event_research_eval_cases.json` +- Test: `tests/test_deep_agents_spike_contract.py` + +**Approach:** +- Compare a simple `create_agent` research specialist with a Deep Agents version on event rules/course research that benefits from planning, context isolation, citations, and multi-step synthesis. +- Use synthetic/public cases only. Give both variants the same model, source access, output schema, and budget. +- Evaluate correctness, source quality, tool trajectory, latency, cost, context isolation, cancellation needs, and integration effort. +- Keep the experiment outside production imports and lockfiles unless the spike is explicitly run. Do not evaluate preview async subagents as a release dependency. +- Promote Deep Agents only through a later explicit architecture decision if it produces a meaningful quality or operability advantage that cannot be obtained from selected LangChain middleware. + +**Patterns to follow:** +- `tests/fixtures/head_coach_eval_cases.json` for comparable cases and sanitized evaluation data. +- Head Coach specialist output contracts from Unit 2. + +**Test scenarios:** +- Contract: both variants return the same validated, citation-bearing specialist recommendation schema. +- Isolation: the specialist cannot mutate athlete, calendar, plan, or Decision Ledger state. +- Failure: unavailable web/source tooling returns a bounded failure recommendation rather than invented event facts. +- Evaluation: the experiment reports comparable call, token, latency, cost, and quality results without affecting default tests. + +**Verification:** +- The experiment ends with an evidence-backed adopt/defer/reject recommendation and creates no production coupling by default. + +## System-Wide Impact + +```mermaid +flowchart TB + Web[Next.js plan and coach UI] --> API[FastAPI routes and services] + API --> Worker[Celery job lifecycle] + API --> Domain[(Coach events, proposals, active plans)] + Worker --> Runtime[Head Coach LangGraph runtime] + API --> Runtime + Runtime --> DomainTools[Invocation-scoped domain tools] + Runtime --> Checkpoints[(PostgreSQL checkpoints)] + Worker --> Domain + Domain --> Web + Runtime --> Trace[Sanitized LangSmith/local telemetry] +``` + +- **Interaction graph:** Initial Generate continues through API job creation and Celery; coach chat continues through the API service and SSE. Both call the shared Head Coach runtime while API/worker services retain transaction ownership. +- **Error propagation:** Model/tool failures become explicit job or turn failures; durable interruptions become `awaiting_input`; provider/tool unavailability is evidence metadata unless the requested capability is genuinely required; commit conflicts remain HTTP/job conflicts rather than silent overwrite. +- **State lifecycle risks:** Stable checkpoint IDs, idempotency claims, versioned active plans, transactional commits, terminal cancellation, and serialized-state rules prevent partial or duplicate state. Checkpoints never authorize mutation by themselves. +- **API surface parity:** Analysis start/status/result/cancel remains compatible; a resume operation and `awaiting_input` state are additive. Coach SSE envelopes remain stable. v2 and v3 artifacts coexist behind `schema_version`. +- **Integration coverage:** Tests must cross API → worker → checkpoint → model mock → artifact validation → active-plan persistence → API read → versioned frontend rendering. Unit-only mocks cannot establish resume or commit-once behavior. +- **Unchanged invariants:** One local owner, loopback-only no-login runtime, optional tracing, no provider requirement, no default real API calls in tests, plan version conflicts, existing quota enforcement, and current safety boundaries remain unchanged. + +## Success Metrics + +- 100% of deterministic safety, schema, provider-free, authorization, idempotency, and resume gates pass. +- New provider-free initial planning performs zero mandatory provider-specialist and zero dedicated deep-reasoning formatter calls; any optional UI Composer is low-cost, separately measured, and cannot alter coaching semantics. +- Mandatory model calls are reduced by at least 50% from the 13-call baseline without reducing supplied coaching context. +- Blinded curated-case evaluation rates the candidate equal or better than the legacy output in at least 80% of cases, with no safety regression. +- A forced worker restart after a completed expensive step resumes without repeating that step and commits one artifact set. +- All existing v2 fixtures remain readable; all new v3 fixtures render deterministically. +- Deep Agents adoption remains an explicit measured decision, not an implicit dependency. + +## Phased Delivery + +### Phase A — Characterize and establish foundations + +- Units 1-4 establish evaluation, ownership contracts, durable runtime, and v3 artifacts without switching production defaults. + +### Phase B — Release-critical vertical slice + +- Unit 5 switches new initial provider-free plans behind a reversible workflow-version selector. +- Release only after quality, safety, compatibility, resume, and public-repo audits pass. + +### Phase C — Converge the ongoing coaching loop + +- Unit 6 moves coach chat, proposals, recap, and daily adaptation to the shared runtime one surface at a time. + +### Phase D — Simplify and decide optional harness adoption + +- Unit 7 removes proven-dead legacy orchestration and aligns docs. +- Unit 8 independently evaluates Deep Agents and may finish before or after the core migration without blocking it. + +## Alternative Approaches Considered + +- **Adopt Deep Agents as the product runtime immediately:** Rejected for the core path because its filesystem/task/subagent defaults exceed current needs, async subagents remain fast-moving, and our canonical state is relational domain data rather than an agent workspace. +- **Keep the fixed graph and merely skip empty provider nodes:** Useful as a short-lived hotfix but does not establish one accountable coach, durable resume, shared tools, or separation between coaching semantics and UI composition. +- **Replace LangGraph entirely with direct OpenAI Responses state:** Rejected because provider-side state conflicts with local-first ownership, durable replay, provider portability, and local observability. +- **Build a custom agent framework:** Rejected because current LangChain middleware and LangGraph runtime already cover the standard tool loop, persistence, interrupts, and streaming. +- **Big-bang rewrite of all AI surfaces:** Rejected because it creates unnecessary release and data risk; existing coach and proposal infrastructure provides safe incremental seams. + +## Risks & Dependencies + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| PostgreSQL checkpoint bootstrap conflicts with the single-baseline migration policy | Medium | High | Require explicit approval, document ownership of checkpoint tables, test fresh install and upgrade, and avoid touching canonical user data. | +| Large structured plan output is unreliable with the selected OpenAI mode | Medium | High | Keep schemas decomposed, compare native/tool strategies in opt-in evals, validate before commit, and preserve a reversible legacy path. | +| New v3 artifact shape breaks calendar/proposal behavior | Medium | High | Add versioned renderers and fixtures first; preserve v2; require stable IDs and typed session fields; migrate proposal parity before retiring v2 mutation code. | +| Checkpoint replay duplicates quota, events, or active plans | Medium | High | Keep side effects out of replayable model nodes, reuse idempotency patterns, use transactional version checks, and test forced retries. | +| Shared prompt becomes a new monolith | Medium | Medium | Keep one identity/core principles but task-specific run profiles and focused response schemas; measure prompt behavior through evals. | +| Dynamic tools hide required context | Low | High | Full Coach Brief is always supplied; dynamic exposure controls capabilities, not evidence pre-filtering; missing tools are observable. | +| Architecture work delays the OSS release indefinitely | Medium | High | Treat Unit 5 as the release-critical slice; defer convergence, legacy deletion, and Deep Agents until after its gates pass. | +| LangSmith leaks athlete context | Low | High | Sanitize trace metadata, keep tracing optional, exclude raw context/reasoning, and add security assertions/release audit. | + +## Documentation / Operational Notes + +- Update `agents_docs/roadmap/now.md` when Unit 1 begins so the architecture migration is visible beside the v2.2 release gate. +- Record the Head Coach ownership decision in `agents_docs/roadmap/decision_log.md` when the release-critical slice is accepted. +- Replace the stale `create_react_agent` recommendation in `services/ai/.agents/skills/langgraph-workflows/SKILL.md` during Unit 7. +- Document checkpoint setup, cleanup, backup expectations, and local troubleshooting without placing secrets in tracked files. +- Keep real-model evaluation opt-in and clearly report model, reasoning profile, latency, calls, tokens, cost, and trace correlation. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md](../brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md) +- Related architecture: `agents_docs/architecture/ai_ui_contract.md` +- Related roadmap: `agents_docs/roadmap/roadmap.md` +- Current planning workflow: `services/ai/langgraph/workflows/planning_workflow.py` +- Current coach runtime: `services/ai/coach/continuum_turn_agent.py` +- Current ownership services: `api/services/coach_turn.py`, `api/services/coach_event_store.py`, `api/services/active_plans.py` +- LangChain v1: https://docs.langchain.com/oss/python/releases/langchain-v1 +- LangChain agents and middleware: https://docs.langchain.com/oss/python/langchain/agents +- LangGraph persistence and interrupts: https://docs.langchain.com/oss/python/langgraph/persistence +- Deep Agents overview: https://docs.langchain.com/oss/python/deepagents/overview diff --git a/docs/plans/2026-08-01-001-fix-certify-v2-2-release-candidate-plan.md b/docs/plans/2026-08-01-001-fix-certify-v2-2-release-candidate-plan.md new file mode 100644 index 0000000..67e5a21 --- /dev/null +++ b/docs/plans/2026-08-01-001-fix-certify-v2-2-release-candidate-plan.md @@ -0,0 +1,380 @@ +--- +title: "fix: Certify the v2.2.0 release candidate" +type: fix +status: active +date: 2026-08-01 +origin: docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md +deepened: 2026-08-01 +--- + +# fix: Certify the v2.2.0 release candidate + +## Overview + +Turn the current wearable- and training-data-provider-free Head Coach and schema-v3 worktree into a reviewed, reproducible v2.2.0 release candidate. OpenAI API access remains required for AI generation. The work covers confidence-gated code review, high-confidence fixes, exact-candidate verification and secret scanning, a clean commit and pull request, exact-SHA CI, browser acceptance, PR demo evidence, and a verified GitHub draft release. Publication remains a separate maintainer decision and is not authorized by this plan. + +## Problem Frame + +The product now completes the local-first athlete journey through profile, competitions, a provider-free OpenAI planning run, schema-v3 season and execution artifacts, the compact calendar, dashboard, and plan-aware coaching. The implementation is locally green, but the release evidence predates the Head Coach migration and the current worktree contains a large cross-layer change. The previous clean-install and secret-scan results therefore cannot certify this candidate (see origin: `docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md`; architecture origin: `docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md`). + +## Requirements Trace + +### Candidate review and invariants + +- R1. Review the complete candidate for correctness, security, data integrity, contract drift, reliability, maintainability, and release-policy compliance; apply only confidence-gated fixes. +- R2. Preserve the provider-free, local-first ownership contract, loopback boundary, full coaching context, fail-fast model repair, and rich schema-v3 UI. +- R3. Preserve existing athlete data: do not inspect, reset, migrate, or delete the maintainer database, secrets, traces, or generated personal artifacts. + +### Candidate identity and verification + +- R4. Produce a fixed candidate commit whose full SHA is the identity for local verification, secret/history scans, remote branch state, CI, browser evidence, and draft release. SHA-bound evidence lives in the PR, draft release, and ignored local evidence—not inside the commit it certifies. +- R5. Run the full backend, frontend, version-governance, migration, and release-audit gates against the exact candidate; any post-candidate fix invalidates and repeats the evidence. +- R6. Repeat the synthetic clean-install provider-free journey in an isolated Compose namespace, including restart persistence and plan-aware coach chat, without retaining model output or private evidence. + +### Remote delivery and draft preparation + +- R7. Push the candidate, create or update its PR, require CI results whose `headSha` equals the candidate SHA, and autonomously fix/repeat until green. +- R8. Create a reviewed GitHub draft release for `v2.2.0`, explicitly verify its draft state and exact target, and do not publish it. + +### Publication boundary + +- R9. Leave external legal review and immutable publication as explicit remaining gates requiring the maintainer's final Go. + +## Scope Boundaries + +- Do not publish the GitHub release, merge to `main`, rewrite Git history, rotate credentials, or change repository visibility. +- Do not touch the maintainer's local athlete database or ignored `.env`, trace, log, data, backup, or generated-artifact contents. +- Do not reintroduce Strava, WHOOP, Garmin, daily sync, weekly recap, hosted auth, payments, or a public-network deployment path. +- Do not implement the optional Deep Agents experiment; it remains non-blocking future work. +- Do not manufacture deterministic coaching fallbacks when model repair is exhausted. + +## Context & Research + +### Relevant Code and Patterns + +- `services/ai/head_coach/` owns the shared Head Coach runtime; `services/ai/head_coach/graph.py` keeps deterministic load/review/commit boundaries around model judgment. +- `services/ai/head_coach/checkpointing.py`, `worker/tasks.py`, and `api/services/active_plans.py` are the critical durability and commit-once seam. +- `config/version_manifest.yaml`, `core/version_manifest.py`, and `web/app/src/lib/generated/version-manifest.ts` define release, database, and UI schema governance. +- `web/app/src/components/plan-viewer/versioned/` and `web/app/src/lib/demo/fixtures/` define strict versioned rendering; dashboard projection must not force schema-v3 artifacts through legacy HTML blocks. +- `.github/workflows/ci.yml` is the exact-commit automated gate and uses Node.js 24. +- `scripts/release_audit.sh` scans an isolated candidate export and reachable Git history and refuses a dirty tree. +- `docs/releases/v2.2.0-verification.md` is the evidence format to update without prompts, model outputs, traces, private logs, databases, or athlete data. + +### Institutional Learnings + +- `docs/solutions/best-practices/head-coach-release-hardening-2026-08-01.md` captures the verified lifecycle and release-hardening invariants from this review. Root/scoped `AGENTS.md`, `agents_docs/architecture/ai_ui_contract.md`, `agents_docs/ops/version_governance.md`, and `docs/local-first/data-preservation.md` remain authoritative contracts. +- Prior release evidence is useful only as a test design; it must be repeated because it certifies an older commit and legacy planning architecture. +- Checkpoint state is disposable execution state, never canonical domain truth. Retry/resume must not duplicate plan commits, usage, cost, or Decision Ledger events. + +### External References + +- Gitleaks v8.30.1 current commands and redaction: https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#commands +- Gitleaks v8.30.1 release and checksums: https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1 +- GitHub exact required-check behavior: https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks +- GitHub draft release management: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository +- GitHub secure workflow and evidence guidance: https://docs.github.com/en/actions/reference/security/secure-use + +## Key Technical Decisions + +| Decision | Direction | Rationale | +|---|---|---| +| Candidate identity | One full Git commit SHA | A branch name or latest run can move and cannot bind audit, CI, and release evidence together. | +| Secret scanner | Verified direct Gitleaks v8.30.1 binary when Docker credential resolution is broken | Avoid modifying the user's Docker credential configuration; verify the official checksum before execution. | +| Scan coverage | Current candidate tree plus `git --all --full-history` | Both present content and reachable history are public-release surfaces. | +| Review fixes | Confidence-gated, source-backed autofix | A large late-stage refactor should not absorb speculative cleanup. | +| Clean install | Disposable Compose project and synthetic athlete only | Proves public setup without risking maintainer data or retaining personal evidence. | +| CI acceptance | PR head OID and every accepted run must equal candidate SHA | Prevents accepting a stale green branch run. | +| Draft release | Explicit draft creation and post-create assertion | GitHub release creation defaults can publish unless draft state is explicit. | + +## Open Questions + +### Resolved During Planning + +- Audit ordering: create a provisional fixed candidate commit, audit that clean exact commit, and create a new candidate plus repeat all evidence if any fix follows. +- Existing `v2.2.0` version: retain it because version governance is already aligned and no public immutable `v2.2.0` tag is assumed; preflight the remote tag before draft creation and abort on a conflicting target. +- Docker scanner failure: use the official direct Gitleaks binary with verified SHA-256 rather than altering global Docker configuration. +- Legal review: record it as an outstanding publication gate; a draft release is allowed, publication is not. +- Exact-SHA evidence: keep final SHA, audit, CI, E2E, and draft-state evidence in ignored local metadata plus the PR/draft release; tracked documentation is finalized before the candidate commit and contains no self-referential SHA claim. +- Acceptance ports: use the documented loopback ports in an exclusive test window. Gracefully stop the maintainer stack without deleting its volumes, inventory it first, run the isolated namespace, then restore the original stack. +- OpenAI credential: use only an already exported runtime credential without opening or copying ignored secret files. Inject it at runtime into the minimum required services, disable optional tracing, retain no request/response content, and fail closed if no authorized runtime credential is available. +- GitHub credentials: use the existing authenticated repository identity with only branch/PR/draft-release permissions; verify the repository before every mutation and never print auth values or invoke merge, force-push, publish, tag-retarget, visibility, or administrative operations. +- Public terminology: “provider-free” always means wearable- and training-data-provider-free; README, onboarding, PR, and release notes state plainly that an OpenAI API key is required for AI generation. +- Clarification acceptance: cover the clarification UI deterministically with a synthetic contract state even if the real OpenAI run does not choose to interrupt. + +### Deferred to Implementation + +- PR number and current review state: discover after the candidate branch is pushed. +- CI failures: diagnose from exact-run logs and fix only concrete failures, then mint a new candidate SHA and repeat affected evidence. +- Whether the synthetic OpenAI run requests clarification: exercise either durable continuation path and record only non-sensitive outcomes. + +## High-Level Release Flow + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + Review[Review complete worktree] --> Fix[Apply confidence-gated fixes] + Fix --> Local[Run local gates] + Local --> Candidate[Create candidate commit] + Candidate --> Audit[Audit exact clean candidate] + Audit -->|failure requiring code change| Fix + Audit -->|pass| Push[Push and update PR] + Push --> CI[Verify exact-SHA CI] + CI -->|failure| Fix + CI -->|pass| E2E[Clean-clone synthetic acceptance] + E2E -->|failure| Fix + E2E -->|pass| Draft +``` + +## Implementation Units + +- [x] **Unit 1: Review the complete candidate** + +**Goal:** Establish whether the worktree satisfies product, architecture, safety, persistence, API/UI contract, and release requirements. + +**Requirements:** R1-R3 + +**Dependencies:** None + +**Files:** +- Review: all changed and untracked source, migration, configuration, documentation, and test files +- Test evidence: `tests/`, `web/app/src/tests/` + +**Approach:** +- Before expensive work, fetch the remote tag namespace read-only and verify that immutable tag `v2.2.0` does not already target a conflicting commit. Stop for a version decision if it does; repeat the check immediately before draft creation to cover races. +- Review the diff by risk surface: secrets/privacy, migration/data integrity, Head Coach correctness and durability, API contracts, worker reliability, schema-v3 UI, provider-free cleanup, documentation, and release operations. +- Check deleted legacy modules for live imports and public contract loss. +- Deduplicate findings and keep only issues supported by concrete code paths or failing gates. +- Maintain an ignored review-coverage ledger mapping every changed, new, generated, and deleted path to a named risk surface and `reviewed/no-finding/finding` status, with explicit entries for migrations, workflows, generated contracts, and removed legacy modules. + +**Test scenarios:** +- Contract: every new schema-v3 producer has a strict parser, renderer, fixture, mutation path where applicable, and explicit unknown-version failure. +- Durability: retry/resume/cancellation cannot duplicate a plan, event, quota, or cost record. +- Security: no key, raw reasoning, athlete context, trace, database, or generated personal artifact is tracked or logged into release evidence. +- Compatibility: supported historical artifact versions remain readable without guessing their schema. +- Release identity: a pre-existing conflicting immutable `v2.2.0` tag halts work before certification effort begins. + +**Verification:** +- Every changed/deleted path is accounted for in the ignored coverage ledger, and every finding has severity, confidence, evidence, and an explicit fix or reason for rejection. + +- [x] **Unit 2: Apply high-confidence release fixes** + +**Goal:** Correct validated findings without broadening product scope or weakening agent autonomy. + +**Requirements:** R1-R3, R5 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: only files implicated by accepted findings +- Test: corresponding Python or TypeScript contract tests +- Modify: release, migration, and operational documentation when it contradicts the actual candidate + +**Approach:** +- Add or strengthen regression coverage before risky behavior changes. +- Preserve fail-fast structured-output repair, deterministic infrastructure boundaries, loopback-only runtime, and existing local data. +- Remove stale release checkmarks/evidence claims that do not apply to the new candidate. + +**Test scenarios:** +- Happy path: provider-free planning, dashboard, calendar, and plan-aware coach surfaces retain the successful behavior already exercised. +- Failure path: invalid model output, unavailable checkpoints, stale proposal versions, and backend failures remain explicit and preserve canonical state. +- Documentation: public commands, Node/Python requirements, Alembic head, removed providers, and supported schemas agree with executable configuration. + +**Verification:** +- Accepted findings are fixed with focused regression evidence; no speculative cleanup remains in the candidate. + +- [x] **Unit 3: Establish full local preflight acceptance** + +**Goal:** Prove code quality, buildability, and migration/version consistency before freezing the SHA. + +**Requirements:** R2-R6 + +**Dependencies:** Unit 2 + +**Files:** +- Test: `tests/test_head_coach_postgres_integration.py` or a focused Alembic upgrade test beside it +- Verify: `.github/workflows/ci.yml`, `config/version_manifest.yaml`, `api/migrations/versions/`, `docker-compose.yml` + +**Approach:** +- Run the complete CI-equivalent suite using the documented Node.js 24 and Pixi environments. +- Run the full lint, type, test, build, version, migration-contract, and static release-contract gates without reading or mutating maintainer data. +- Treat this as preflight only. Exact-candidate clean-install and real-model evidence begins after a committed SHA exists. + +**Test scenarios:** +- Automated happy path: backend and frontend suites, Node.js 24 production build, and version governance pass together. +- Migration contract: a fresh synthetic database can upgrade through the declared Alembic head without touching the maintainer database. +- Existing-data upgrade: a disposable database at revision `001_initial_local_first` containing a synthetic owner and historical active-plan rows upgrades to `002_head_coach_checkpoints` while preserving row identity and readability. +- Static release contract: loopback bindings, provider-free public paths, and forbidden artifact checks remain enforced. + +**Verification:** +- All deterministic local gates pass and the worktree is ready to be frozen into a candidate commit. + +- [ ] **Unit 4: Freeze and audit the exact candidate** + +**Goal:** Create one clean candidate commit and certify its present content and reachable history. + +**Requirements:** R3-R5 + +**Dependencies:** Unit 3 + +**Files:** +- Test: `tests/test_release_audit_contract.py` +- Modify: `scripts/release_audit.sh` +- Verify: `.gitleaks.toml`, `scripts/release_audit.sh`, `.gitignore` + +**Approach:** +- Commit the intentionally reviewed scope, confirm a clean worktree, and capture the full SHA and tree identity. +- Fetch published remote branch and tag refs without force-pushing, rewriting, or deleting history. Ensure the audit copies and scans remote-tracking refs in addition to local heads and tags, and record the scanned ref set so remote-only published ancestry is not omitted. +- Obtain the official Gitleaks v8.30.1 Linux binary and checksum manifest in temporary ignored storage, verify its SHA-256, and run both candidate-directory and all-reachable-history scans with full redaction. +- Do not commit or upload scanner reports. If code or documentation changes after the candidate commit, create a new candidate and repeat all SHA-bound evidence. + +**Test scenarios:** +- Candidate scan: tracked candidate content produces zero findings and a failing scanner exit cannot be mistaken for a pass. +- History scan: all branches/tags reachable through `--all --full-history` produce zero findings. +- Remote-only history: published remote branch ancestry is included even when it has no local branch counterpart. +- Failure path: checksum mismatch, unavailable scanner, dirty tree, or report parse failure blocks progress. + +**Verification:** +- Clean exact SHA has passing fully redacted directory/history audits and non-sensitive evidence outside the certified commit. + +- [ ] **Unit 5: Push the candidate and drive exact-SHA CI to green** + +**Goal:** Put the audited candidate under remote review and accept only CI for that exact object. + +**Requirements:** R4, R5, R7 + +**Dependencies:** Unit 4 + +**Files:** +- Modify if needed: `.github/workflows/ci.yml` and files implicated by concrete CI failures +- Update: pull request title/body/checklist and release-evidence links + +**Approach:** +- Push the branch, create or update its PR, and record the remote PR head OID. +- Compare `.github/workflows/ci.yml` with the trusted `main` baseline and the Definition-of-Done commands in root/scoped `AGENTS.md`. Reject candidate-controlled CI that removes, weakens, conditionally skips, or makes non-blocking any expected Node 24 install, frontend lint/type/test/build, version-governance, Ruff, MyPy, or full-test gate. +- Wait for required checks; compare PR `headRefOid` and every accepted workflow `headSha` with the candidate SHA. +- Diagnose concrete failures from logs. Every fix produces a new candidate and repeats local gates, audit, push, and exact-SHA comparison. +- Retry an unchanged external/flaky failure at most twice after root-cause classification. Do not mutate code for an unreproduced platform, permission, quota, or download failure; after two unchanged retries, record the external condition as a blocker instead of looping indefinitely. + +**Test scenarios:** +- Happy path: required CI passes and all accepted runs target the exact candidate SHA. +- Stale run: a green run for an earlier branch commit is rejected as evidence. +- Weakened workflow: exact-SHA CI is rejected if a required baseline gate was removed, skipped, or made non-blocking in the candidate workflow. +- Failure path: dependency install, Node 24, version governance, lint, type, test, or build failure is reproduced and fixed before retry. + +**Verification:** +- PR is reviewable and all required checks are green for its exact head SHA. + +- [ ] **Unit 6: Complete exact-candidate clean-install and browser evidence** + +**Goal:** Verify the exact remote candidate from a clean checkout, exercise the real provider-free product, and attach concise visual evidence to the PR without exposing private data. + +**Requirements:** R2, R3, R6-R7 + +**Dependencies:** Unit 5 + +**Files:** +- Modify if a defect is found: affected `web/app/` code and tests +- Update: pull request description with sanitized demo evidence + +**Approach:** +- Clone the remote exact candidate SHA into a separate path and follow only the public setup documentation with a unique random Compose project, new named volumes, synthetic local owner, and before/after resource inventory. Reserve the documented loopback ports by gracefully stopping—but never deleting—the existing maintainer stack, then restore it after acceptance. Never reuse the current `.env`, Compose project, `LOCAL_OWNER_USER_ID`, database, or volume. +- With only an already exported supported OpenAI runtime credential, save a synthetic profile and A-race; generate persisted schema-v3 season and 28-day execution artifacts; exercise clarification/repair if invoked; ask a plan-specific coach question; restart normally and confirm persistence and commit-once behavior. Do not open or copy ignored secret files, enable LangSmith, persist the credential in an image/config file, or retain request/response content. +- Evaluate the generated result with a human product rubric recorded only as pass/fail: season and execution strategy agree, declared availability and constraints are honored, sessions are actionable without wearable data, material uncertainty is explicit, injury context is handled conservatively, and the coach answer accurately references the active plan. +- Exercise dashboard, 28-day calendar, selected-day details, season strategy, clarification card, and coach chat at desktop and mobile widths using only that synthetic namespace. +- Confirm compact calendar hierarchy, progressive disclosure of rich LLM content, visible failure states, keyboard access, and no hydration/console errors at representative 1440×900 desktop and 390×844 mobile viewports. At 200% zoom there is no page-level horizontal overflow; interactive controls expose accessible names and visible focus, disclosures expose expanded/collapsed semantics, and primary touch targets are at least 44×44 CSS pixels. +- Exercise the clarification branch deterministically through a sanitized synthetic job state: enter awaiting-input, submit or cancel, show recoverable failure, resume once, and reach a visible terminal state. The real-model run exercises the same path only when the Head Coach requests it naturally. +- Record a short sanitized feature walkthrough for the PR; do not include keys, traces, private athlete data, or model prompts. + +**Test scenarios:** +- Desktop/mobile: calendar remains primary and rich rationale is available on demand without overflowing the viewport. +- Real-model terminal state: profile and A-race are saved, season/execution schema-v3 artifacts are active, the coach response is plan-specific, no provider/device claim is invented, and restart preserves state without duplicate active plans. +- Interaction states: initial loading, profile-without-plan, generation in progress, coach response in progress, clarification awaiting input, and recoverable backend failure each explain the state, preserve known data, expose the next valid action, and transition cleanly. +- Clarification: synthetic coverage guarantees the submit, cancel, resume, duplicate-submit, and error branches even when the real model does not interrupt. +- Error state: an unreachable API shows an honest preserved-data message rather than fabricated onboarding state. +- Safety: rendered semantic blocks cannot execute model-authored HTML or scripts. +- Cleanup: on success or failure, preserve only explicitly sanitized status metadata in ignored temporary storage, then remove the exact disposable namespace and synthetic volumes before the release workflow completes. Never export environment values, prompts, model output, traces, request bodies, or response bodies. + +**Verification:** +- Exact-candidate clean install and browser checks pass, non-sensitive evidence is bound to the SHA, and a sanitized feature video is linked from the PR. + +- [ ] **Unit 7: Create and verify the GitHub draft release** + +**Goal:** Prepare the complete v2.2.0 release artifact for maintainer review without publishing it. + +**Requirements:** R4, R8-R9 + +**Dependencies:** Units 5 and 6 + +**Files:** +- Verify: committed `CHANGELOG.md`, `docs/local-first/release-checklist.md`, and `docs/releases/v2.2.0-verification.md` +- Update remotely: draft release notes and SHA-bound evidence only + +**Approach:** +- Repeat the early preflight for tag `v2.2.0`. If it now exists, require it to resolve to the exact candidate SHA; otherwise abort rather than retargeting it. +- Create the release explicitly as a draft targeted at the candidate SHA, then fetch it and assert tag, target, URL, and `isDraft=true`. +- Release notes are finalized before the candidate commit, then copied into the remote draft without modifying the certified tree. They lead with the complete athlete outcome and no-wearable-required boundary, state that OpenAI API access is required for AI generation, then explain local-first ownership, Head Coach architecture, known limitations, setup, AI/medical/privacy caveats, and the wearable-/training-data-provider-free scope. +- Leave legal review and publication unchecked and request the maintainer's final Go only after external legal review is complete. + +**Test scenarios:** +- Happy path: draft exists, is not published, targets the exact candidate, and contains reviewed notes. +- Conflict: an existing mismatched tag blocks draft creation. +- Safety: no `.env`, secret report, database, raw trace, private screenshot, prompt, or model output is attached. + +**Verification:** +- Reviewed remote release remains a draft on the exact green candidate SHA; no immutable release was published. + +## System-Wide Impact + +```mermaid +flowchart TB + Source[Reviewed source and docs] --> Local[Local CI-equivalent gates] + Source --> Data[Migration and data-preservation review] + Source --> UI[Browser and responsive review] + Local --> SHA[Exact candidate SHA] + Data --> SHA + SHA --> Audit[Secret and history audit] + SHA --> Remote[PR and exact-SHA CI] + UI --> Remote + Audit --> Draft[Verified draft release] + Remote --> Draft +``` + +- **Interaction graph:** Source contracts flow through API, worker, LangGraph checkpoints, PostgreSQL artifacts, versioned React rendering, local acceptance, GitHub CI, and the draft release. +- **Error propagation:** Any source change after candidate creation invalidates downstream evidence and returns execution to local verification and audit. +- **State lifecycle risks:** Maintainer data is outside scope; only disposable synthetic stacks may be created and destroyed. Remote draft state is created but never published. +- **API surface parity:** Existing supported plan schemas and coach/API envelopes remain explicit; unknown versions fail visibly. +- **Integration coverage:** Automated tests are necessary but not sufficient; exact-candidate migration, restart, OpenAI, browser, and remote-CI evidence close the cross-layer gaps. +- **Unchanged invariants:** Local owner, loopback binding, explicit LLM key, provider-free coaching, optional tracing, transactional domain ownership, and final maintainer publication authority remain unchanged. + +## Risks & Dependencies + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Large deletion hides a live legacy dependency | Medium | High | Reference search, import/startup tests, contract review, and exact CI before accepting deletion. | +| Retry commits artifacts twice | Low | High | Review domain transaction identity and dedicated restart/duplicate-delivery tests. | +| Migration damages local data | Low | High | Never exercise it on maintainer data; test fresh/upgrade paths only in disposable databases. | +| Acceptance credential leaks | Low | Critical | Use only an already exported runtime credential, inject it into minimum required services, disable tracing, retain no content/log bundle, and fail closed if unavailable. | +| Secret scanner appears green without running | Medium | High | Verify official binary checksum and scanner exit; record version and status, not sensitive report content. | +| CI passes a stale branch commit | Medium | High | Compare PR OID and workflow `headSha` to the full candidate SHA. | +| Browser/video captures private state | Medium | High | Use sanitized synthetic fixtures or disposable synthetic data and inspect every frame before attaching. | +| Draft release is accidentally published | Low | Critical | Create explicitly as draft and immediately assert remote `isDraft=true`; never invoke publish. | +| Legal review remains outstanding | High | High | Keep publication blocked and checklist factual; draft preparation does not waive the gate. | + +## Documentation / Operational Notes + +- Update stale statements that fresh installs stop at migration `001`; the manifest now identifies `002_head_coach_checkpoints` as Alembic head. +- Before freezing the candidate, reset stale checklist claims and finalize `CHANGELOG.md`, verification methodology, and release-note source without embedding a future self-referential SHA. +- Distinguish historical V1 artifact compatibility from planning documents that use “V2” as a product-release label. +- Record candidate SHA, clean-tree assertion, tool versions, UTC timestamps, command outcomes, CI URLs and matching head SHAs, dependency lock hashes, and draft URL/state. Never record secret values or private athlete/model content. +- The release checklist must describe current evidence rather than retain checkmarks from the pre-Head-Coach candidate. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-07-13-athlete-first-oss-release-requirements.md](../brainstorms/2026-07-13-athlete-first-oss-release-requirements.md) +- Architecture origin: `docs/brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md` +- Architecture plan: `docs/plans/2026-07-19-001-refactor-head-coach-runtime-plan.md` +- Release checklist: `docs/local-first/release-checklist.md` +- Prior verification record: `docs/releases/v2.2.0-verification.md` +- GitHub workflow: `.github/workflows/ci.yml` +- Release audit: `scripts/release_audit.sh` diff --git a/docs/releases/v2.2.0-verification.md b/docs/releases/v2.2.0-verification.md new file mode 100644 index 0000000..9d6defa --- /dev/null +++ b/docs/releases/v2.2.0-verification.md @@ -0,0 +1,59 @@ +# v2.2.0 Release Verification + +Status: **HISTORICAL EVIDENCE ONLY — DOES NOT CERTIFY THE CURRENT CANDIDATE** + +Attempt date: 2026-07-13 + +Historically smoke-tested code: isolated commit `b54c07b8aff61f03f0e395274ad0bbbfbe90385a`, based on branch `feat/athlete-first-oss-release` before the durable Head Coach and schema-v3 migration. + +The results below document an earlier product path and remain useful as acceptance-test design. They do **not** certify the current v2.2.0 candidate, its Head Coach runtime, revision `002_head_coach_checkpoints`, schema-v3 artifacts, calendar renderer, release audit, remote CI, or screenshots. Current candidate evidence must be produced again against one fixed full commit SHA. + +This record contains only non-sensitive outcomes. It intentionally excludes environment values, prompts, model output, traces, database contents, raw logs, and smoke-test screenshots. + +## Environment + +- Linux `6.18.33.2-microsoft-standard-WSL2` on x86_64 +- Docker `28.5.1`; Docker Compose `v5.0.2` +- Pixi `0.48.2` +- Required frontend runtime: Node.js `v24.18.0` +- Host-default Node.js `v20.20.0` was below the documented requirement and was not used for frontend acceptance +- Final disposable Compose project: `paced_rc_20260713c` +- Final disposable volumes: `paced_rc_20260713c_pixi_env` and `paced_rc_20260713c_postgres_data` + +## Acceptance Results + +- The candidate was copied into an isolated clone and installed with the documented Node.js 24 requirement. +- Web, API, worker, beat, PostgreSQL, and Redis started successfully; host-visible application and infrastructure ports remained loopback-bound. +- With no LLM key, first-run readiness identified the missing key and plan generation returned actionable setup guidance. +- With only an OpenAI key configured, LLM readiness became healthy while external training-data providers and LangSmith remained absent. +- A fully synthetic athlete profile and synthetic A-priority half marathon reached 100% first-run readiness in `declared_only` mode. +- The real OpenAI workflow completed all 17 stages and persisted an analysis report, seven-phase season roadmap, and four-week plan containing exactly 28 dated days. +- The measured workflow took about 18 minutes 51 seconds. The shipped local example configuration now allows 30 minutes (29 minutes 30 seconds soft limit), leaving useful headroom for ordinary provider latency. +- The generated season and 28-day plans used the declared goal, availability, sports, race, physiology anchors, and prior Achilles constraint without inventing recent activity, load, HRV, sleep, recovery, or readiness measurements. +- The visible analysis and persisted expert context contained no connector directives such as connect, reconnect, or restore tracking. Optional-source absence was represented as measurement uncertainty rather than a product failure or proof of inactivity. +- The athlete-facing analysis led with the race target, a practical Achilles gate, and an actionable next step rather than missing-device-data warnings. +- Coach chat answered a final-plan-specific question about the August 4 `4x5'` steady session, corrected the premise using the preceding July 28 session, explained the block progression, and supplied an Achilles-aware fallback without provider-derived claims or a connector requirement. +- A normal disposable-stack stop/start preserved the season plan, all 28 days, source-job identity, and the active Coach thread. +- Provider-free UI copy consistently stated that athlete-declared context powers plan generation and Coach chat. +- Ruff, MyPy, the Python suite, frontend tests/type-check, ESLint, and a production Next.js build passed locally. +- A clean synthetic release-candidate commit passed the pinned tracked-file and reachable-history release audit. +- Disposable projects, volumes, images, processes, browser profiles, and the isolated clone were removed after evidence capture. Pre-existing `tele_garmin_pixi_env` and `tele_garmin_postgres_data` volumes remained present; no existing athlete data was opened or modified. + +## Findings Closed During Acceptance + +1. OpenAI initially returned `429 insufficient_quota`. After billing quota was added, the real workflow completed. +2. The job UI originally surfaced a downstream missing-output error for quota exhaustion. Required AI-stage errors now stop before invalid downstream planning, and OpenAI quota exhaustion maps to actionable billing/provider guidance. +3. The first successful no-provider output over-centered absent connected data and told the athlete to restore tracking. A shared provider-optional coaching contract now applies to all three experts, synthesis, and analysis formatting; the final real output passed the connector-directive and unsupported-claim review. +4. A VS Code/WSL interruption stopped the first recovery stack after most model work had completed. That interrupted run was excluded from acceptance. The final result came from a fresh disposable data namespace and completed normally within the configured job window. + +## Current Candidate Gates + +- Complete the Head Coach/schema-v3 local verification and disposable migration-preservation test. +- Recapture sanitized schema-v3 README screenshots; the July images are historical. +- Audit the clean fixed candidate, including remote-tracking refs, and record the exact SHA. +- Push the final audited candidate and complete exact-commit GitHub CI evidence. +- Complete the external Germany-based legal review and apply any required corrections. +- Prepare and inspect the GitHub draft release. +- Obtain explicit maintainer approval immediately before publishing the immutable `v2.2.0` release. + +Public publication remains **No-Go** until fresh candidate-bound evidence and every publication gate pass. diff --git a/docs/releases/v2.2.0.md b/docs/releases/v2.2.0.md new file mode 100644 index 0000000..64be3a6 --- /dev/null +++ b/docs/releases/v2.2.0.md @@ -0,0 +1,28 @@ +# paced.coach v2.2.0 + +paced.coach is now a complete local-first AI endurance coaching app: describe your goals, training history, availability, and constraints; generate a season roadmap and a day-by-day 28-day execution block; then continue the same plan context in coach chat. + +## Highlights + +- **No wearable required.** One OpenAI API key plus athlete-declared context is enough for the baseline coaching path. +- **One continuous coaching flow.** Profile, race calendar, season strategy, 28-day plan, calendar, and plan-aware coach chat live in the same app. +- **One durable Head Coach.** Initial planning, clarification/resume, plan changes, and chat share a source-of-ownership runtime while PostgreSQL domain records remain canonical. +- **Schema-v3 plan experience.** Rich Season Strategy and 28-day Execution artifacts render through versioned React components, with the compact calendar first and detailed rationale available on demand. +- **Provider-free by design.** External training-data OAuth, imports, and automated source sync are not part of v2.2.0. Coaching follow-ups reason from athlete-declared context and the saved plan. +- **Local-first ownership.** The Next.js app, FastAPI API, Celery/LangGraph workers, PostgreSQL, and Redis run locally through Docker Compose. +- **Safer public baseline.** The release removes hosted auth, payments, private athlete artifacts, and hosted deployment assumptions from the public tree, and adds a pinned secret/history audit. + +## Important Boundaries + +- The no-login application is intended for a single local owner and binds host-visible services to loopback by default. Do not expose it directly to a public network. +- Coaching output is generated through the OpenAI API. It can be incomplete or wrong and requires athlete judgment. +- paced.coach provides training support, not medical diagnosis or treatment. Stop and seek qualified care for concerning symptoms or injuries. +- Athlete data is stored in the local PostgreSQL volume by default. OpenAI receives the context required for model calls; optional LangSmith tracing creates an additional intentional network path. +- Resumable Head Coach checkpoints are also stored in local PostgreSQL. They can contain working athlete and plan context, are normally removed seven days after a run becomes terminal, and are deleted by the protected local data reset. +- The coach must not claim recent activity, compliance, load, HRV, sleep, recovery, or readiness trends unless the athlete explicitly provides them. + +## Candidate Verification + +OpenAI API access is required for AI generation. Fresh Head Coach/schema-v3 verification, exact-SHA CI, secret/history audit, sanitized screenshots, and a disposable restart-persistence run must pass before this draft can certify v2.2.0. The July 2026 verification record predates the Head Coach migration and is retained only as historical evidence. Other operating systems remain best-effort until independently verified. + +See the [setup guide](../local-first/setup.md), [AI coaching limitations](../local-first/ai-coaching-limitations.md), and [release verification record](v2.2.0-verification.md) before running the app. diff --git a/docs/solutions/best-practices/head-coach-release-hardening-2026-08-01.md b/docs/solutions/best-practices/head-coach-release-hardening-2026-08-01.md new file mode 100644 index 0000000..2e14d9e --- /dev/null +++ b/docs/solutions/best-practices/head-coach-release-hardening-2026-08-01.md @@ -0,0 +1,166 @@ +--- +title: Hardening agentic plan generation across durable boundaries +date: 2026-08-01 +category: best-practices +module: head-coach-release-pipeline +problem_type: best_practice +component: development_workflow +severity: high +applies_when: + - releasing an owner-scoped LLM workflow that creates durable domain artifacts + - resuming LangGraph runs after human clarification + - relying on PostgreSQL locks, checkpoints, migrations, or transaction semantics + - replacing provider-backed workflows while retaining historical local data + - migrating UI payloads from legacy HTML blocks to versioned semantic blocks +related_components: [api, background-job, database, frontend, continuous-integration, release-audit] +tags: [head-coach, langgraph, postgresql, idempotency, advisory-locks, durable-resume, provider-free, release-hardening] +--- + +# Hardening agentic plan generation across durable boundaries + +## Context + +An agentic workflow can look correct in mocked tests while still allowing duplicate expensive runs, ambiguous human-in-the-loop retries, or contradictory terminal state. The Head Coach release review found that correctness crossed several independently failing boundaries: HTTP admission, Celery delivery, LangGraph checkpoints, the canonical plan transaction, PostgreSQL-only semantics, and historical UI payloads. + +The reusable lesson is to treat durable agent execution and canonical domain publication as coordinated but independently failing state machines. Agent intelligence owns coaching judgment; deterministic infrastructure owns concurrency, idempotency, validation, persistence, and schema dispatch. + +## Guidance + +### Serialize admission per owner + +Acquire a PostgreSQL transaction-scoped advisory lock before checking availability and inserting the job. Derive a stable signed 64-bit key from the owner ID: + +```python +async def lock_owner_plan_generation(db: AsyncSession, *, user_id: UUID) -> None: + await db.execute( + text("SELECT pg_advisory_xact_lock(:lock_key)"), + {"lock_key": owner_plan_generation_lock_key(user_id)}, + ) +``` + +The lock, availability check, job insert, persisted input snapshot within the mutable lifecycle config, and commit must share the same transaction lifetime. Every nonterminal ownership state reserves the slot, including `awaiting_input`: + +```python +AnalysisJob.status.in_(("pending", "running", "awaiting_input")) +``` + +A normal check-then-insert query is not a concurrency boundary: two transactions can both observe no active job before either commit becomes visible. + +### Make the domain transaction authoritative + +Commit the active season plan, active execution plan, Decision Ledger event, usage record, cost record, and terminal job result in one transaction under an owner-scoped active-plan lock. An idempotent repeat is valid only when both active plans already reference the same `source_job_id`. + +After any later worker error, roll back the same synchronous SQLAlchemy worker session and reload the committed job status before deciding to fail it: + +```python +db.rollback() +db.expire_all() +committed_status = db.execute( + select(AnalysisJob.status).where(AnalysisJob.id == job.id) +).scalar_one_or_none() +if committed_status == "completed": + return +``` + +This prevents a checkpoint-finalization failure from overwriting a successful canonical plan publication with `failed`. Checkpoints remain resumable execution state, not the source of truth for activated plans. + +### Bind resume identity to request content + +An idempotency key alone does not identify the request. Bind it to a deterministic hash of the clarification answer after request-schema normalization: + +```python +{ + "idempotency_key": request.idempotency_key, + "answer": request.answer, + "answer_hash": sha256(request.answer.encode("utf-8")).hexdigest(), +} +``` + +The state machine must enforce: + +- same key and same answer: return the existing state without another enqueue; +- same key and different answer: `409 Conflict`; +- enqueue failure: restore `awaiting_input`, preserve the interrupt, and remove the unconsumed resume payload; +- terminal success: remove the extra plaintext answer from the job config but retain the key plus hash as a size-bounded terminal receipt for the job's lifetime. + +This supports exact retries before and after completion without retaining another plaintext copy in the job config. The durable LangGraph checkpoint can still contain the answer until the configured checkpoint-retention cleanup runs. + +### Treat the database row as a durable dispatch intent + +Queue delivery is not atomic with the API transaction. A process can stop after the `pending` job commit and before the first broker publish. Periodically scan sufficiently old `pending` rows with `FOR UPDATE SKIP LOCKED` and re-enqueue them. Worker admission must lock the job row and reject a `running` row owned by another Celery task ID, so duplicate broker deliveries remain harmless while same-task retries can continue. + +### Verify production database semantics in CI + +Mocked sessions and SQLite cannot certify PostgreSQL advisory locks, production JSONB behavior, LangGraph checkpoint durability, or the migration chain. CI should start disposable PostgreSQL, apply Alembic to `head`, and explicitly execute durability tests before the broad suite. + +The focused PostgreSQL suite should prove: + +- checkpoint restoration after graph and pool recreation; +- overlapping worker-claim exclusion; +- owner-scoped generation admission serializes across two transactions; +- duplicate workflow delivery does not increment versions or duplicate events, usage, or cost; +- a fresh database and an existing-data fixture migrate to the declared head. + +Release history verification has the same fail-closed property: compare `git ls-remote --heads --tags` with local tracking refs before scanning history, so a stale clone cannot certify an incomplete public surface. + +### Keep provider-free runtime ownership truthful + +Removing provider UI is insufficient if runtime projections still query provider-era workflows. Establish provider-free behavior at mounted routes, task registration, agent tool policy, prompts, dependencies, dashboard queries, copy, tests, and assets. + +Provider-era tables may remain solely for non-destructive reset and local data ownership. Historical UI payloads can also outlive their producer. Keep current V3 plan rendering strict, but use a narrow, explicit compatibility union for persisted recap blocks: + +```ts +type RecapBlock = SemanticBlockV3 | UiHtmlBlock; + +if ("content_html" in block) { + return ; +} +return ; +``` + +The HTML path must sanitize its input. This compatibility boundary does not permit HTML in new V3 artifacts. + +## Why This Matters + +The architecture becomes coherent when every durable boundary has one owner: + +- the advisory lock owns generation admission; +- the nonterminal job owns the generation slot; +- the answer-bound receipt owns resume idempotency; +- the atomic database transaction owns active artifacts and terminal success; +- the checkpoint owns only resumable execution progress; +- the schema-versioned renderer owns current artifacts; +- a narrow compatibility adapter owns persisted legacy payloads. + +Without these boundaries, partial failure creates split-brain state: a failed job with active plans, two generations for one owner, a resume key that aliases different answers, or a current renderer guessing at old data. + +## When to Apply + +- A long-running agent can pause for human input or survive worker restarts. +- One run activates multiple related domain artifacts. +- Queue delivery and checkpoint persistence happen outside the canonical commit. +- Correctness depends on PostgreSQL-specific transaction behavior. +- Provider or schema migrations retain locally persisted historical data. +- A release claim must be bound to one exact commit rather than a mutable branch or stale clone. + +## Examples + +For each new lifecycle state, answer these questions in code review and executable tests: + +1. Does the state still own the generation slot? +2. Is it terminal, retryable, or resumable? +3. Which database row or transaction is authoritative? +4. Is retry identity bound to request content? +5. What happens if enqueue, checkpoint, or transport fails immediately before or after commit? +6. Is the behavior covered against PostgreSQL rather than only mocks? +7. Does the UI dispatch by `schema_version` or an explicit legacy discriminator? + +The certified failure-injection suite includes concurrent starts, duplicate deliveries, stranded-dispatch recovery, same/different-answer resumes, broker failure, post-commit failure reconciliation, partial active-plan receipts, fresh database migrations, stale remote refs, and legacy recap rendering. An injected real-checkpointer failure after the canonical commit and an existing-data migration fixture remain valuable follow-up coverage where the release environment can support them safely. + +## Related + +- [Head Coach runtime refactor plan](../../plans/2026-07-19-001-refactor-head-coach-runtime-plan.md) +- [Head Coach, artifact, and UI ownership contract](../../../agents_docs/architecture/ai_ui_contract.md) +- [Head Coach architecture requirements](../../brainstorms/2026-07-19-head-coach-agent-architecture-requirements.md) +- [v2.2 release-candidate certification plan](../../plans/2026-08-01-001-fix-certify-v2-2-release-candidate-plan.md) +- [Architecture decision log](../../../agents_docs/roadmap/decision_log.md) diff --git a/pixi.lock b/pixi.lock index b81c7aa..09dcb7b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -53,7 +53,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl @@ -70,7 +69,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl @@ -88,7 +86,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/cf/b183dba8667f7b6d1be546fb8089a3bc3bc12b514f551f5317ae03815770/langchain-1.2.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl @@ -96,7 +93,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/07/057dc1aa7991115fca53f1fa6573a7cc0dd296c05360c672cc67fdb6245b/langgraph-1.1.10-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/5ada0bef4013ef5ae53a0ca1de5736517f1076a54d313f156ca545ec65d5/langgraph_prebuilt-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3d/2396ebcff70bcb5a2bbe3420f1e8a05062bb0a2ee9c253b1b500e743f572/langsmith-0.4.60-py3-none-any.whl @@ -108,7 +106,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl @@ -117,11 +114,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -205,7 +204,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl @@ -222,7 +220,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl @@ -240,7 +237,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/cf/b183dba8667f7b6d1be546fb8089a3bc3bc12b514f551f5317ae03815770/langchain-1.2.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl @@ -248,7 +244,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/07/057dc1aa7991115fca53f1fa6573a7cc0dd296c05360c672cc67fdb6245b/langgraph-1.1.10-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/5ada0bef4013ef5ae53a0ca1de5736517f1076a54d313f156ca545ec65d5/langgraph_prebuilt-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3d/2396ebcff70bcb5a2bbe3420f1e8a05062bb0a2ee9c253b1b500e743f572/langsmith-0.4.60-py3-none-any.whl @@ -260,7 +257,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl - pypi: https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl @@ -269,11 +265,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl @@ -358,7 +356,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl @@ -375,7 +372,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl @@ -392,7 +388,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/cf/b183dba8667f7b6d1be546fb8089a3bc3bc12b514f551f5317ae03815770/langchain-1.2.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl @@ -400,7 +395,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/07/057dc1aa7991115fca53f1fa6573a7cc0dd296c05360c672cc67fdb6245b/langgraph-1.1.10-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/5ada0bef4013ef5ae53a0ca1de5736517f1076a54d313f156ca545ec65d5/langgraph_prebuilt-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3d/2396ebcff70bcb5a2bbe3420f1e8a05062bb0a2ee9c253b1b500e743f572/langsmith-0.4.60-py3-none-any.whl @@ -412,7 +408,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl - pypi: https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl @@ -421,11 +416,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl @@ -510,7 +507,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl @@ -528,7 +524,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl @@ -546,7 +541,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/cf/b183dba8667f7b6d1be546fb8089a3bc3bc12b514f551f5317ae03815770/langchain-1.2.17-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl @@ -554,7 +548,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/07/057dc1aa7991115fca53f1fa6573a7cc0dd296c05360c672cc67fdb6245b/langgraph-1.1.10-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/ef/5ada0bef4013ef5ae53a0ca1de5736517f1076a54d313f156ca545ec65d5/langgraph_prebuilt-1.0.13-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/3d/2396ebcff70bcb5a2bbe3420f1e8a05062bb0a2ee9c253b1b500e743f572/langsmith-0.4.60-py3-none-any.whl @@ -566,7 +561,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl @@ -575,11 +569,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl @@ -755,28 +751,6 @@ packages: requires_dist: - typing-extensions>=4.0.0 ; python_full_version < '3.9' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl - name: anthropic - version: 0.97.0 - sha256: 8a1a472dfabcfc0c52ff6a3eecf724ac7e07107a2f6e2367be55ceb42f5d5613 - requires_dist: - - anyio>=3.5.0,<5 - - distro>=1.7.0,<2 - - docstring-parser>=0.15,<1 - - httpx>=0.25.0,<1 - - jiter>=0.4.0,<1 - - pydantic>=1.9.0,<3 - - sniffio - - typing-extensions>=4.14,<5 - - aiohttp ; extra == 'aiohttp' - - httpx-aiohttp>=0.1.9 ; extra == 'aiohttp' - - boto3>=1.28.57 ; extra == 'aws' - - botocore>=1.31.57 ; extra == 'aws' - - boto3>=1.28.57 ; extra == 'bedrock' - - botocore>=1.31.57 ; extra == 'bedrock' - - mcp>=1.0 ; python_full_version >= '3.10' and extra == 'mcp' - - google-auth[requests]>=2,<3 ; extra == 'vertex' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl name: anyio version: 4.12.1 @@ -1340,17 +1314,6 @@ packages: version: 1.9.0 sha256: 7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 requires_python: '>=3.6' -- pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - name: docstring-parser - version: 0.17.0 - sha256: cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708 - requires_dist: - - pre-commit>=2.16.0 ; python_full_version >= '3.9' and extra == 'dev' - - pydoctor>=25.4.0 ; extra == 'dev' - - pytest ; extra == 'dev' - - pydoctor>=25.4.0 ; extra == 'docs' - - pytest ; extra == 'test' - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl name: fastapi version: 0.115.14 @@ -1658,15 +1621,6 @@ packages: - langchain-together ; extra == 'together' - langchain-xai ; extra == 'xai' requires_python: '>=3.10.0,<4.0.0' -- pypi: https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl - name: langchain-anthropic - version: 1.4.3 - sha256: 65466e0f2f95909a009708f2958e917dfdbfab79c612b4484a30866a85e1f291 - requires_dist: - - anthropic>=0.96.0,<1.0.0 - - langchain-core>=1.3.2,<2.0.0 - - pydantic>=2.7.4,<3.0.0 - requires_python: '>=3.10.0,<4.0.0' - pypi: https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl name: langchain-classic version: 1.0.1 @@ -1764,14 +1718,24 @@ packages: - pydantic>=2.7.4 - xxhash>=3.5.0 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl name: langgraph-checkpoint - version: 4.0.0 - sha256: 3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784 + version: 4.1.1 + sha256: 25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e requires_dist: - langchain-core>=0.2.38 - ormsgpack>=1.12.0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl + name: langgraph-checkpoint-postgres + version: 3.1.0 + sha256: 814cce2ef35d792bf07b090a95eed004f1acac0724fe6605536b13f6d1e7032c + requires_dist: + - langgraph-checkpoint>=4.1.0,<5.0.0 + - orjson>=3.11.5 + - psycopg-pool>=3.2.0 + - psycopg>=3.2.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/69/ef/5ada0bef4013ef5ae53a0ca1de5736517f1076a54d313f156ca545ec65d5/langgraph_prebuilt-1.0.13-py3-none-any.whl name: langgraph-prebuilt version: 1.0.13 @@ -2342,26 +2306,6 @@ packages: version: 1.1.0 sha256: 1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl - name: narwhals - version: 2.15.0 - sha256: cbfe21ca19d260d9fd67f995ec75c44592d1f106933b03ddd375df7ac841f9d6 - requires_dist: - - cudf>=24.10.0 ; extra == 'cudf' - - dask[dataframe]>=2024.8 ; extra == 'dask' - - duckdb>=1.1 ; extra == 'duckdb' - - ibis-framework>=6.0.0 ; extra == 'ibis' - - packaging ; extra == 'ibis' - - pyarrow-hotfix ; extra == 'ibis' - - rich ; extra == 'ibis' - - modin ; extra == 'modin' - - pandas>=1.1.3 ; extra == 'pandas' - - polars>=0.20.4 ; extra == 'polars' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - pyspark>=3.5.0 ; extra == 'pyspark' - - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' - - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' - requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 md5: 47e340acb35de30501a76c7c799c41d7 @@ -2540,7 +2484,7 @@ packages: - pypi: ./ name: paced-coach version: 2.2.0 - sha256: 0f16f536e9319edab8dc44d09272a303b72a9d06b7419015b3d338694f19cb5c + sha256: 1aa131c2e5c9805e21b25eae225bfbae5259df4df35d45d371b9a7fd499f0688 requires_python: '>=3.13' editable: true - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl @@ -2946,46 +2890,6 @@ packages: - pkg:pypi/platformdirs?source=hash-mapping size: 23922 timestamp: 1764950726246 -- pypi: https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl - name: plotly - version: 6.5.2 - sha256: 91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4 - requires_dist: - - narwhals>=1.15.1 - - packaging - - numpy ; extra == 'express' - - kaleido>=1.1.0 ; extra == 'kaleido' - - pytest ; extra == 'dev-core' - - requests ; extra == 'dev-core' - - ruff==0.11.12 ; extra == 'dev-core' - - plotly[dev-core] ; extra == 'dev-build' - - build ; extra == 'dev-build' - - jupyter ; extra == 'dev-build' - - plotly[dev-build] ; extra == 'dev-optional' - - plotly[kaleido] ; extra == 'dev-optional' - - anywidget ; extra == 'dev-optional' - - colorcet ; extra == 'dev-optional' - - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev-optional' - - geopandas ; extra == 'dev-optional' - - inflect ; extra == 'dev-optional' - - numpy ; extra == 'dev-optional' - - orjson ; extra == 'dev-optional' - - pandas ; extra == 'dev-optional' - - pdfrw ; extra == 'dev-optional' - - pillow ; extra == 'dev-optional' - - plotly-geo ; extra == 'dev-optional' - - polars[timezone] ; extra == 'dev-optional' - - pyarrow ; extra == 'dev-optional' - - pyshp ; extra == 'dev-optional' - - pytz ; extra == 'dev-optional' - - scikit-image ; extra == 'dev-optional' - - scipy ; extra == 'dev-optional' - - shapely ; extra == 'dev-optional' - - statsmodels ; extra == 'dev-optional' - - vaex ; python_full_version < '3.10' and extra == 'dev-optional' - - xarray ; extra == 'dev-optional' - - plotly[dev-optional] ; extra == 'dev' - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl name: pluggy version: 1.6.0 @@ -3040,6 +2944,73 @@ packages: version: 0.4.1 sha256: 381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl + name: psycopg + version: 3.3.4 + sha256: b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a + requires_dist: + - typing-extensions>=4.6 ; python_full_version < '3.13' + - tzdata ; sys_platform == 'win32' + - psycopg-c==3.3.4 ; implementation_name != 'pypy' and extra == 'c' + - psycopg-binary==3.3.4 ; implementation_name != 'pypy' and extra == 'binary' + - psycopg-pool ; extra == 'pool' + - anyio>=4.0 ; extra == 'test' + - mypy>=1.19.0 ; implementation_name != 'pypy' and extra == 'test' + - pproxy>=2.7 ; extra == 'test' + - pytest>=6.2.5 ; extra == 'test' + - pytest-cov>=3.0 ; extra == 'test' + - pytest-randomly>=3.5 ; extra == 'test' + - ast-comments>=1.1.2 ; extra == 'dev' + - black>=26.1.0 ; extra == 'dev' + - codespell>=2.2 ; extra == 'dev' + - cython-lint>=0.16 ; extra == 'dev' + - dnspython>=2.1 ; extra == 'dev' + - flake8>=4.0 ; extra == 'dev' + - isort[colors]>=6.0 ; extra == 'dev' + - isort-psycopg ; extra == 'dev' + - mypy>=1.19.0 ; extra == 'dev' + - pre-commit>=4.0.1 ; extra == 'dev' + - types-setuptools>=57.4 ; extra == 'dev' + - types-shapely>=2.0 ; extra == 'dev' + - wheel>=0.37 ; extra == 'dev' + - sphinx>=9.1 ; extra == 'docs' + - furo==2025.12.19 ; extra == 'docs' + - sphinx-autobuild>=2025.8.25 ; extra == 'docs' + - sphinx-autodoc-typehints>=3.10.2 ; extra == 'docs' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl + name: psycopg-binary + version: 3.3.4 + sha256: fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl + name: psycopg-binary + version: 3.3.4 + sha256: 75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl + name: psycopg-binary + version: 3.3.4 + sha256: dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: psycopg-binary + version: 3.3.4 + sha256: c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl + name: psycopg-pool + version: 3.3.1 + sha256: 2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 + requires_dist: + - typing-extensions>=4.6 + - anyio>=4.0 ; extra == 'test' + - mypy>=1.14 ; extra == 'test' + - pproxy>=2.7 ; extra == 'test' + - pytest>=6.2.5 ; extra == 'test' + - pytest-cov>=3.0 ; extra == 'test' + - pytest-randomly>=3.5 ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: psycopg2-binary version: 2.9.11 diff --git a/pixi.toml b/pixi.toml index 6648867..d1d816d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -14,20 +14,19 @@ pre-commit = ">=4.3.0,<5" [pypi-dependencies] # Install the project itself in editable mode paced-coach = { path = ".", editable = true } -anthropic = ">=0.97.0, <0.98" pydantic = ">=2.12, <3" pydantic-settings = ">=2.0, <3" requests = ">=2.32.0, <3" langchain = ">=1.0.0, <2" langchain-openai = ">=1.0.0, <2" -langchain-anthropic = ">=1.4.3, <2" langchain-community = ">=0.4, <0.5" langchain-core = ">=1.0.0, <2" langgraph = ">=1.0.0, <2" +langgraph-checkpoint-postgres = "==3.1.0" +psycopg = { version = ">=3.3.0, <4", extras = ["binary", "pool"] } langsmith = ">=0.4.37, <0.5" numpy = ">=2.3.4, <3" pandas = ">=2.3.3, <3" -plotly = ">=6.3.1, <7" python-dotenv = ">=1.1.1, <2" setuptools = "*" pytest = ">=8.4.2, <9" @@ -72,7 +71,6 @@ dead-code = "vulture --config vulture.toml" dead-code-all = "vulture --config vulture.toml --min-confidence 60" dead-code-verbose = "vulture --config vulture.toml --verbose" -seed-active-plans = "PYTHONPATH=. python cli/seed_active_plans.py" # Web API (Phase 2) api = "uvicorn api.main:app --reload --host 127.0.0.1 --port 8000" diff --git a/pyproject.toml b/pyproject.toml index bfe182e..b314d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ markers = [ [project] name = "paced-coach" version = "2.2.0" -description = "AI-powered endurance coaching engine and SaaS foundation" +description = "Local-first AI endurance coaching application" requires-python = ">=3.13" authors = [ {name = "Developer"} @@ -24,7 +24,7 @@ authors = [ # Dependencies now managed by Pixi in pixi.toml [tool.setuptools] -packages = ["core", "services", "cli"] +packages = ["core", "services"] [tool.black] diff --git a/scripts/check_version_governance.py b/scripts/check_version_governance.py index f22ebb2..e364216 100644 --- a/scripts/check_version_governance.py +++ b/scripts/check_version_governance.py @@ -13,20 +13,21 @@ def _check_renderer_coverage() -> list[str]: manifest = get_version_manifest() errors: list[str] = [] - supported_versions = manifest.compatibility.ui_schema.supported_versions - - for version in supported_versions: - analysis_renderer = REPO_ROOT / f"web/app/src/components/plan-viewer/versioned/analysis-view-v{version}.tsx" - season_renderer = REPO_ROOT / f"web/app/src/components/plan-viewer/versioned/season-plan-view-v{version}.tsx" - weekly_renderer = REPO_ROOT / f"web/app/src/components/plan-viewer/versioned/weekly-plan-view-v{version}.tsx" + support_by_kind = manifest.compatibility.ui_schema.supported_versions_by_kind + + renderer_names = { + "analysis": "analysis-view", + "season": "season-plan-view", + "weekly": "weekly-plan-view", + } + for kind, supported_versions in support_by_kind.items(): + for version in supported_versions: + renderer = REPO_ROOT / f"web/app/src/components/plan-viewer/versioned/{renderer_names[kind]}-v{version}.tsx" + if not renderer.exists(): + errors.append(f"Missing renderer file: {renderer}") + + for version in manifest.compatibility.ui_schema.supported_versions: fixture_dir = REPO_ROOT / f"web/app/src/lib/demo/fixtures/v{version}" - - if not analysis_renderer.exists(): - errors.append(f"Missing renderer file: {analysis_renderer}") - if not season_renderer.exists(): - errors.append(f"Missing renderer file: {season_renderer}") - if not weekly_renderer.exists(): - errors.append(f"Missing renderer file: {weekly_renderer}") if not fixture_dir.exists(): errors.append(f"Missing fixture directory: {fixture_dir}") @@ -43,6 +44,11 @@ def _check_release_consistency() -> list[str]: if pyproject_version != release_version: errors.append(f"Release version mismatch: manifest={release_version}, pyproject.toml={pyproject_version}") + pixi_data = tomllib.loads((REPO_ROOT / "pixi.toml").read_text(encoding="utf-8")) + pixi_version = pixi_data.get("project", {}).get("version") + if pixi_version != release_version: + errors.append(f"Release version mismatch: manifest={release_version}, pixi.toml={pixi_version}") + package_json_data = json.loads((REPO_ROOT / "web/app/package.json").read_text(encoding="utf-8")) web_version = package_json_data.get("version") if web_version != release_version: diff --git a/scripts/release_audit.sh b/scripts/release_audit.sh new file mode 100755 index 0000000..06eb871 --- /dev/null +++ b/scripts/release_audit.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +set -uo pipefail + +GITLEAKS_VERSION="v8.30.1" +GITLEAKS_IMAGE="zricethezav/gitleaks:${GITLEAKS_VERSION}" +MAX_TRACKED_BYTES="${RELEASE_AUDIT_MAX_TRACKED_BYTES:-10485760}" + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + echo "[ERROR] release audit must run inside a Git repository" + exit 1 +fi + +cd "$repo_root" +report_dir="${RELEASE_AUDIT_REPORT_DIR:-$repo_root/.tmp/release-audit}" +tracked_export="$report_dir/tracked" +history_repo="$report_dir/history.git" +gitleaks_config="$repo_root/.gitleaks.toml" +error_count=0 + +ok() { + echo "[OK] $1" +} + +info() { + echo "[INFO] $1" +} + +error() { + echo "[ERROR] $1" + error_count=$((error_count + 1)) +} + +prepare_report_directory() { + mkdir -p "$report_dir" + rm -rf "$tracked_export" "$history_repo" + rm -f "$report_dir/history.json" "$report_dir/history.scanner.log" + rm -f "$report_dir/tracked.json" "$report_dir/tracked.scanner.log" + rm -f "$report_dir/scanned-refs.txt" + mkdir -p "$tracked_export" +} + +check_working_tree() { + local status + status="$(git status --porcelain --untracked-files=all)" + if [[ -n "$status" ]]; then + error "working tree is not clean" + printf '%s\n' "$status" | sed 's/^/ /' + return + fi + ok "working tree is clean" +} + +report_ignored_local_paths() { + local candidates=( + ".env" + "web/app/.env.local" + "data" + "logs" + "celerybeat-schedule" + ) + local present=() + local candidate + for candidate in "${candidates[@]}"; do + if [[ -e "$candidate" ]] && git check-ignore -q -- "$candidate"; then + present+=("$candidate") + fi + done + + if [[ ${#present[@]} -eq 0 ]]; then + ok "no ignored local secret/config paths detected" + return + fi + + local joined + joined="$(IFS=', '; echo "${present[*]}")" + info "local secret/config paths present (contents not inspected): $joined" +} + +is_forbidden_tracked_path() { + local path="$1" + local basename="${path##*/}" + + case "$path" in + .env.example|web/app/.env.example) + return 1 + ;; + data/*|logs/*|tmp/*|.tmp/*) + return 0 + ;; + esac + + case "$basename" in + .env|.env.*|*.db|*.dump|*.ipynb|*.log|*.sqlite|*.sqlite3) + return 0 + ;; + esac + + return 1 +} + +check_tracked_paths() { + local path + local path_errors=0 + while IFS= read -r path; do + if is_forbidden_tracked_path "$path"; then + error "forbidden tracked release path: $path" + path_errors=$((path_errors + 1)) + continue + fi + + if [[ -f "$path" ]]; then + local size + size="$(wc -c < "$path")" + if ((size > MAX_TRACKED_BYTES)); then + error "oversized tracked release artifact: $path (${size} bytes)" + path_errors=$((path_errors + 1)) + fi + fi + done < <(git ls-files) + + if [[ $path_errors -eq 0 ]]; then + ok "tracked paths contain no forbidden private/generated artifacts" + fi +} + +check_workflow_secret_references() { + local matches + matches="$(git grep -n -E 'secrets\.[A-Za-z0-9_]+' -- '.github/workflows/*.yml' '.github/workflows/*.yaml' 2>/dev/null || true)" + if [[ -n "$matches" ]]; then + error "workflow secret references require explicit release review" + printf '%s\n' "$matches" | sed -E 's/(secrets\.)[A-Za-z0-9_]+/\1[redacted-name]/g' | sed 's/^/ /' + return + fi + ok "workflows contain no repository secret references" +} + +check_network_bindings() { + if [[ ! -f docker-compose.yml ]]; then + ok "no Docker Compose port surface present" + return + fi + + local unsafe_bindings + unsafe_bindings="$( + grep -n -E "^[[:space:]]*-[[:space:]]*[\"']?((0\\.0\\.0\\.0|\\[?::\\]?):)?[0-9]+:[0-9]+" docker-compose.yml || true + )" + if [[ -n "$unsafe_bindings" ]]; then + error "Docker Compose contains a non-loopback host port binding" + printf '%s\n' "$unsafe_bindings" | sed 's/^/ /' + return + fi + ok "Docker Compose host ports remain explicitly loopback-bound" +} + +check_hosted_ops_files() { + local matches + matches="$( + git ls-files | grep -E '(^|/)(fly\.toml|vercel\.json|netlify\.toml|render\.yaml|serverless\.yml)$|(^|/)(terraform|k8s|kubernetes)/' || true + )" + if [[ -n "$matches" ]]; then + error "hosted deployment artifacts require explicit release review" + printf '%s\n' "$matches" | sed 's/^/ /' + return + fi + ok "no hosted deployment artifacts detected" +} + +check_remote_ref_freshness() { + local remote + local remote_count=0 + while IFS= read -r remote; do + [[ -z "$remote" ]] && continue + remote_count=$((remote_count + 1)) + local remote_refs + if ! remote_refs="$(git ls-remote --heads --tags "$remote" 2>/dev/null)"; then + error "unable to verify configured remote refs; fetch/network access is required for release audit" + continue + fi + local oid ref local_ref local_oid + while IFS=$'\t' read -r oid ref; do + [[ -z "$oid" || -z "$ref" || "$ref" == *'^{}' ]] && continue + if [[ "$ref" == refs/heads/* ]]; then + local_ref="refs/remotes/$remote/${ref#refs/heads/}" + else + local_ref="$ref" + fi + local_oid="$(git rev-parse --verify "$local_ref" 2>/dev/null || true)" + if [[ "$local_oid" != "$oid" ]]; then + error "configured remote refs are missing or stale; fetch all heads and tags before release audit" + break + fi + done <<< "$remote_refs" + done < <(git remote) + if [[ $remote_count -eq 0 ]]; then + ok "no configured remotes require freshness verification" + elif [[ $error_count -eq 0 ]]; then + ok "configured remote heads and tags match local tracking refs" + fi +} + +export_tracked_files() { + if ! git archive --format=tar HEAD | tar -xf - -C "$tracked_export"; then + error "failed to create isolated tracked-file export" + return 1 + fi + ok "isolated tracked-file export created" +} + +export_release_history() { + if ! git init --bare -q "$history_repo"; then + error "failed to initialize isolated history repository" + return 1 + fi + + local ref + local symref + local ref_count=0 + : > "$report_dir/scanned-refs.txt" + chmod 600 "$report_dir/scanned-refs.txt" + while IFS=' ' read -r ref symref; do + [[ -z "$ref" ]] && continue + # refs/remotes//HEAD is normally symbolic. Copying the concrete + # remote branch already covers its history, while fetching the symbolic + # alias into a bare repository is ambiguous and can fail. + [[ -n "$symref" ]] && continue + if ! git --git-dir="$history_repo" fetch -q "$repo_root" "+$ref:$ref"; then + error "failed to copy one release history ref; names are recorded only in the private audit report" + return 1 + fi + printf '%s\n' "$ref" >> "$report_dir/scanned-refs.txt" + ref_count=$((ref_count + 1)) + done < <( + git for-each-ref \ + --format='%(refname) %(symref)' \ + refs/heads refs/tags refs/remotes + ) + + if [[ $ref_count -eq 0 ]]; then + error "no release history refs found" + return 1 + fi + ok "isolated release history created from $ref_count local and remote-tracking refs" + info "scanned ref names recorded in the untracked private audit report" +} + +run_gitleaks_direct() { + local mode="$1" + local target="$2" + local report_path="$3" + local scanner_log="$4" + local history_args=() + if [[ "$mode" == "git" ]]; then + history_args=(--log-opts "--all --full-history") + fi + "$RELEASE_AUDIT_GITLEAKS_BIN" "$mode" "${history_args[@]}" \ + --config "$gitleaks_config" \ + --redact=100 \ + --report-format json \ + --report-path "$report_path" \ + "$target" >"$scanner_log" 2>&1 +} + +run_gitleaks_docker() { + local mode="$1" + local target="$2" + local report_name="$3" + local scanner_log="$4" + local history_args=() + if [[ "$mode" == "git" ]]; then + history_args=(--log-opts "--all --full-history") + fi + docker run --rm --network none \ + --mount "type=bind,src=$target,dst=/scan,readonly" \ + --mount "type=bind,src=$gitleaks_config,dst=/config/.gitleaks.toml,readonly" \ + --mount "type=bind,src=$report_dir,dst=/reports" \ + "$GITLEAKS_IMAGE" "$mode" "${history_args[@]}" \ + --config /config/.gitleaks.toml \ + --redact=100 \ + --report-format json \ + --report-path "/reports/$report_name" \ + /scan >"$scanner_log" 2>&1 +} + +run_secret_scan() { + local label="$1" + local mode="$2" + local target="$3" + local report_name="$4" + local report_path="$report_dir/$report_name" + local scanner_log="${report_path%.json}.scanner.log" + local status=0 + + if [[ -n "${RELEASE_AUDIT_GITLEAKS_BIN:-}" ]]; then + run_gitleaks_direct "$mode" "$target" "$report_path" "$scanner_log" || status=$? + else + if ! command -v docker >/dev/null 2>&1; then + error "$label secret scan unavailable: Docker is required" + return + fi + run_gitleaks_docker "$mode" "$target" "$report_name" "$scanner_log" || status=$? + fi + + if [[ $status -ne 0 ]]; then + error "$label secret scan failed; inspect the fully redacted report under .tmp/release-audit" + return + fi + ok "$label secret scan passed" +} + +prepare_report_directory +check_working_tree +report_ignored_local_paths +check_tracked_paths +check_workflow_secret_references +check_network_bindings +check_hosted_ops_files +check_remote_ref_freshness + +if export_tracked_files; then + run_secret_scan "tracked-file" "dir" "$tracked_export" "tracked.json" +fi + +if export_release_history; then + run_secret_scan "history" "git" "$history_repo" "history.json" +fi + +if [[ $error_count -ne 0 ]]; then + echo "[ERROR] public-release audit failed with $error_count issue(s)" + exit 1 +fi + +echo "[OK] public-release audit passed" diff --git a/scripts/sync_version_manifest.py b/scripts/sync_version_manifest.py index 7943737..65cd4ae 100644 --- a/scripts/sync_version_manifest.py +++ b/scripts/sync_version_manifest.py @@ -29,6 +29,7 @@ def _render_ts(manifest: VersionManifest) -> str: f"export const VERSION_MANIFEST = {manifest_json} as const;\n\n" "export type VersionManifest = typeof VERSION_MANIFEST;\n\n" "export const SUPPORTED_SCHEMA_VERSIONS = VERSION_MANIFEST.compatibility.ui_schema.supported_versions;\n" + "export const SUPPORTED_SCHEMA_VERSIONS_BY_KIND = VERSION_MANIFEST.compatibility.ui_schema.supported_versions_by_kind;\n" "export const DEFAULT_SCHEMA_VERSION = VERSION_MANIFEST.compatibility.ui_schema.default_version;\n" ) diff --git a/services/ai/.agents/skills/langgraph-workflows/SKILL.md b/services/ai/.agents/skills/langgraph-workflows/SKILL.md index 5c1da90..1037c90 100644 --- a/services/ai/.agents/skills/langgraph-workflows/SKILL.md +++ b/services/ai/.agents/skills/langgraph-workflows/SKILL.md @@ -1,58 +1,59 @@ --- name: langgraph-workflows -description: Patterns for implementing LangGraph agents, state management, checkpoints, and control flow. Use when working in services/ai. +description: Current patterns for Head Coach agents, durable LangGraph lifecycles, checkpoints, interrupts, and domain ownership in services/ai. --- -# LangGraph Workflow Patterns - -## 1. State Schema & Reducers -- **Typed state**: Use `TypedDict` or Pydantic `BaseModel` for graph state. :contentReference[oaicite:2]{index=2} -- **Reducers are required for merge semantics**: - - Default behavior is overwrite on update. - - For `messages`, prefer `add_messages` so updates append *and* can overwrite existing messages by ID. :contentReference[oaicite:3]{index=3} -- **Pattern (messages state)**: - - `messages: Annotated[list[AnyMessage], add_messages]` - -## 2. Graph Construction -- **Builder**: Use `StateGraph(State)` and explicit `START`/`END` edges for the “spine”. -- **Compile**: `app = builder.compile(checkpointer=...)` -- **Separate I/O schemas (when needed)**: Define input/output schemas explicitly instead of forcing everything into one mega-state. :contentReference[oaicite:4]{index=4} - -## 3. Nodes -- **Async**: Prefer `async def` nodes for uniformity (I/O, LLM calls, tools). -- **Recommended signature**: - - `async def node(state: AgentState, config: RunnableConfig) -> dict | Command[...]` -- **State updates**: - - Return `{"some_key": new_value}` for simple updates. - - Use `Command(update=..., goto=...)` when the node also chooses the next hop. :contentReference[oaicite:5]{index=5} -- **Typing**: - - Use `Command[Literal["node_a", "node_b", END]]` to declare allowed destinations. :contentReference[oaicite:6]{index=6} - -## 4. Control Flow (pick the right mechanism) -- **Static edges**: Use `add_edge("a", "b")` for fixed sequencing. -- **Conditional edges**: Use `add_conditional_edges("router", router_fn, ...)` for branching based on state. -- **Command routing**: Prefer `Command(goto=...)` when routing is best decided *inside* a node (common for agent handoffs). :contentReference[oaicite:7]{index=7} -- **Fan-out / Map-Reduce**: - - Use the **Send API** (return `Send(...)` objects from conditional routing) when the number of downstream tasks is dynamic. :contentReference[oaicite:8]{index=8} -- **Loops**: - - Use a loop edge or `Command(goto=...)` + enforce a recursion limit in config when appropriate. :contentReference[oaicite:9]{index=9} - -## 5. Tool Execution -- **ToolNode**: Use the prebuilt tool execution node for model tool-calls inside graphs. :contentReference[oaicite:10]{index=10} -- **ReAct**: For quick starts, `create_react_agent(...)` is fine; for custom flows, wire model/tool nodes yourself. - -## 6. Interrupts (Human-in-the-loop) -- **Pause**: Call `interrupt(value)` inside a node to stop execution and persist state (requires a checkpointer). -- **Resume**: Re-invoke with `Command(resume=...)`; the resume value is returned back into the paused node. :contentReference[oaicite:11]{index=11} - -## 7. Checkpointing & Threading -- **Dev**: `InMemorySaver` for debugging/testing. :contentReference[oaicite:12]{index=12} -- **Prod**: `PostgresSaver` / `AsyncPostgresSaver` (package: `langgraph-checkpoint-postgres`). :contentReference[oaicite:13]{index=13} -- **Configurable keys**: - - Always pass `thread_id` when using a checkpointer. :contentReference[oaicite:14]{index=14} - - Use `checkpoint_ns` for namespacing (multi-tenant / multi-workflow). - - Optional: `checkpoint_id` to load a specific checkpoint (time travel / replay). - -## 8. Subgraphs -- **Composition**: Use subgraphs for reusable modules. -- **Memory**: Parent checkpointer typically propagates; compile subgraphs with their own checkpointer only if they need separate internal memory. :contentReference[oaicite:15]{index=15} +# Head Coach and LangGraph Patterns + +## Choose the runtime intentionally + +- Use `langchain.agents.create_agent` for bounded model/tool loops, middleware, dynamic tools, and structured output. +- Use `StateGraph` around an agent when the product lifecycle needs durable stages, interrupts, resume, deterministic review, or atomic commit boundaries. +- Do not recreate an agent loop with manual `AIMessage`/`ToolMessage` routing or use the deprecated prebuilt `create_react_agent` helper. +- Do not introduce a multi-agent framework until a grounded eval shows that a focused specialist materially improves the Head Coach's result. + +## Shared Head Coach factory + +- Instantiate models only through `services.ai.model_config.ModelSelector`. +- Select behavior through an explicit semantic run profile from `services/ai/head_coach/run_profiles.py`. +- Build ongoing agents through `services/ai/head_coach/agent.py` so identity, middleware, call limits, reasoning effort, and `ToolStrategy` repair remain consistent. +- Use strict Pydantic response schemas. Return validation errors to the responsible model for bounded repair; after exhaustion, fail visibly. +- Never synthesize rule-authored coaching content as a fallback. + +## Context and tools + +- Pass the complete relevant local context. Do not truncate or pre-score it to save tokens. +- Expose tools by semantic capability. Provider tools exist only when a connected provider is observable at run start. +- Tools are read-only unless a profile and deterministic service boundary explicitly grant proposal authority. +- Specialists and research tools advise; the Head Coach owns the final judgment. + +## Durable graph state + +- Keep graph state typed and serializable. Use Pydantic or `TypedDict`; use reducers only where merge semantics are required. +- Use explicit `START`/`END` edges for deterministic lifecycle stages. +- Use `Command(update=..., goto=...)` when a node owns both its state update and route. +- Use `interrupt(value)` for material clarification, then resume the same thread with `Command(resume=...)`. +- Compile production graphs with the process-safe PostgreSQL checkpointer provider. In-memory savers are test-only. +- Always pass a stable owner-scoped `thread_id`. Keep root `checkpoint_ns` empty/reserved for LangGraph internals. + +## Ownership and side effects + +- LangGraph checkpoints own execution progress, not canonical product state. +- PostgreSQL domain rows and Coach Events own accepted plans, decisions, and user-visible history. +- Keep side effects in idempotent service/commit nodes. A retry or resume must not duplicate events, quota consumption, costs, or plan writes. +- Acquire the per-run execution claim before advancing a durable graph. Cancellation and terminal domain state win over stale delivery. +- The model may propose changes; deterministic services validate schema/version and commit only after the correct product approval boundary. + +## Observability + +- Emit semantic product lifecycle events, not internal graph node names. +- Trace sanitized metadata, tool names, artifact IDs, validation outcomes, and cost/token totals. +- Never trace credentials, provider tokens, database sessions, raw private context, or hidden reasoning. + +## Verification + +- Test successful structured output and model self-repair exhaustion. +- Test provider-free tool exposure and optional-provider capability gating. +- Test checkpoint resume, duplicate delivery, cancellation, concurrency claims, and atomic commit recovery. +- Test that no direct mutation occurs before proposal acceptance. +- Run Ruff, Mypy, mocked provider tests, and the affected frontend schema/rendering tests. diff --git a/services/ai/ai_settings.py b/services/ai/ai_settings.py index a171111..747b49b 100644 --- a/services/ai/ai_settings.py +++ b/services/ai/ai_settings.py @@ -5,73 +5,30 @@ class AgentRole(Enum): - SUMMARIZER = "summarizer" - METRICS_EXPERT = "metrics_expert" - PHYSIOLOGY_EXPERT = "physiology_expert" - ACTIVITY_EXPERT = "activity_expert" - SYNTHESIS = "synthesis" - WORKOUT = "workout" # DEPRECATED alias — use WEEKLY_PLANNER - WEEKLY_PLANNER = "weekly_planner" - SEASON_PLANNER = "season_planner" - ANALYSIS_FORMATTER = "analysis_formatter" - PLAN_FORMATTER = "plan_formatter" - COACH = "coach" + HEAD_COACH = "head_coach" + SPECIALIST = "specialist" + UI_COMPOSER = "ui_composer" + MEMORY = "memory" COACH_TRIAGE = "coach_triage" - WEEKLY_RECAP = "weekly_recap" - DAILY_UPDATE = "daily_update" -def _gpt_5_5_search_assignments() -> dict[AgentRole, str]: +def _gpt_5_6_sol_search_assignments() -> dict[AgentRole, str]: return { - AgentRole.SUMMARIZER: "gpt-5.5-search", - AgentRole.ANALYSIS_FORMATTER: "gpt-5.5-search", - AgentRole.PLAN_FORMATTER: "gpt-5.5-search", - AgentRole.METRICS_EXPERT: "gpt-5.5-search", - AgentRole.PHYSIOLOGY_EXPERT: "gpt-5.5-search", - AgentRole.ACTIVITY_EXPERT: "gpt-5.5-search", - AgentRole.SYNTHESIS: "gpt-5.5-search", - AgentRole.WEEKLY_PLANNER: "gpt-5.5-search", - AgentRole.SEASON_PLANNER: "gpt-5.5-search", - AgentRole.COACH: "gpt-5.5-search", - AgentRole.COACH_TRIAGE: "gpt-5.5-search", - AgentRole.WEEKLY_RECAP: "gpt-5.5-search", - AgentRole.DAILY_UPDATE: "gpt-5.5-search", + AgentRole.HEAD_COACH: "gpt-5.6-sol-search", + AgentRole.SPECIALIST: "gpt-5.6-sol-search", + AgentRole.UI_COMPOSER: "gpt-5.6-sol-search", + AgentRole.MEMORY: "gpt-5.6-sol-search", + AgentRole.COACH_TRIAGE: "gpt-5.6-sol-search", } def _gpt_5_5_assignments() -> dict[AgentRole, str]: return { - AgentRole.SUMMARIZER: "gpt-5.5", - AgentRole.ANALYSIS_FORMATTER: "gpt-5.5", - AgentRole.PLAN_FORMATTER: "gpt-5.5", - AgentRole.METRICS_EXPERT: "gpt-5.5-search", - AgentRole.PHYSIOLOGY_EXPERT: "gpt-5.5-search", - AgentRole.ACTIVITY_EXPERT: "gpt-5.5-search", - AgentRole.SYNTHESIS: "gpt-5.5", - AgentRole.WEEKLY_PLANNER: "gpt-5.5-search", - AgentRole.SEASON_PLANNER: "gpt-5.5-search", - AgentRole.COACH: "gpt-5.5", + AgentRole.HEAD_COACH: "gpt-5.5", + AgentRole.SPECIALIST: "gpt-5.5-search", + AgentRole.UI_COMPOSER: "gpt-5.5", + AgentRole.MEMORY: "gpt-5.5", AgentRole.COACH_TRIAGE: "gpt-5.5", - AgentRole.WEEKLY_RECAP: "gpt-5.5-search", - AgentRole.DAILY_UPDATE: "gpt-5.5-search", - } - - -def _claude_assignments() -> dict[AgentRole, str]: - return { - AgentRole.SUMMARIZER: "claude-4", - AgentRole.ANALYSIS_FORMATTER: "claude-4", - AgentRole.PLAN_FORMATTER: "claude-4", - AgentRole.METRICS_EXPERT: "claude-4", - AgentRole.PHYSIOLOGY_EXPERT: "claude-4", - AgentRole.ACTIVITY_EXPERT: "claude-4", - AgentRole.SYNTHESIS: "claude-4", - AgentRole.WEEKLY_PLANNER: "claude-4", - AgentRole.SEASON_PLANNER: "claude-4", - AgentRole.COACH: "claude-4", - AgentRole.COACH_TRIAGE: "claude-4", - AgentRole.WEEKLY_RECAP: "claude-4", - AgentRole.DAILY_UPDATE: "claude-4", } @@ -81,11 +38,10 @@ class AISettings: model_assignments: dict[AIMode, dict[AgentRole, str]] = field( default_factory=lambda: { - AIMode.STANDARD: _gpt_5_5_search_assignments(), + AIMode.STANDARD: _gpt_5_6_sol_search_assignments(), AIMode.COST_EFFECTIVE: _gpt_5_5_assignments(), AIMode.DEVELOPMENT: _gpt_5_5_assignments(), AIMode.PRO: _gpt_5_5_assignments(), - AIMode.ANTHROPIC: _claude_assignments(), } ) diff --git a/services/ai/coach/athlete_model_agent.py b/services/ai/coach/athlete_model_agent.py index 8de7b3c..43c25ac 100644 --- a/services/ai/coach/athlete_model_agent.py +++ b/services/ai/coach/athlete_model_agent.py @@ -1,31 +1,35 @@ from __future__ import annotations import json -from datetime import UTC, datetime +from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field -from services.ai.ai_settings import AgentRole -from services.ai.model_config import ModelSelector -from services.ai.utils.retry_handler import QUICK_RETRY_CONFIG, retry_with_backoff -from services.ai.utils.structured_output import coerce_structured_output +from services.ai.head_coach.agent import build_head_coach_agent, invoke_head_coach_agent +from services.ai.head_coach.schemas import RunProfileName class _ConfidenceScore(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + field_name: str = Field(..., min_length=1, max_length=120) confidence: float = Field(..., ge=0.0, le=1.0) class _TransientStateNote(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + topic: str = Field(..., min_length=1, max_length=120) status: str = Field(default="unknown", min_length=1, max_length=40) summary: str = Field(..., min_length=1, max_length=500) - first_observed_at: str | None = Field(default=None, max_length=64) - last_observed_at: str | None = Field(default=None, max_length=64) + first_observed_at: datetime | None = None + last_observed_at: datetime | None = None class AthleteModelSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + training_preferences: list[str] = Field(default_factory=list) schedule_constraints: list[str] = Field(default_factory=list) response_patterns: list[str] = Field(default_factory=list) @@ -50,95 +54,30 @@ class AthleteModelSummary(BaseModel): """ -def _normalize_timestamp(raw_value: object) -> str | None: - if isinstance(raw_value, datetime): - parsed = raw_value - elif isinstance(raw_value, str): - try: - parsed = datetime.fromisoformat(raw_value) - except ValueError: - return None - else: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC).isoformat() - - -def _normalize_transient_state_notes(raw_notes: object) -> list[dict[str, str | None]]: - if not isinstance(raw_notes, list): - return [] - normalized: list[dict[str, str | None]] = [] - for note in raw_notes: - if not isinstance(note, dict): - continue - topic = str(note.get("topic", "")).strip() - summary = str(note.get("summary", "")).strip() - if not topic or not summary: - continue - status = str(note.get("status", "unknown")).strip().lower() or "unknown" - normalized.append( - { - "topic": topic, - "status": status, - "summary": summary, - "first_observed_at": _normalize_timestamp(note.get("first_observed_at")), - "last_observed_at": _normalize_timestamp(note.get("last_observed_at")), - } - ) - return normalized[:12] - - async def summarize_athlete_model( *, previous_model: dict, recent_events: list[dict], invoke_config: dict[str, Any] | None = None, ) -> AthleteModelSummary: - llm = ModelSelector.get_llm(AgentRole.COACH) - llm_with_structure = llm.with_structured_output(AthleteModelSummary, method="json_schema") - - prompt = { - "previous_model": previous_model, - "recent_events": recent_events, - } - - async def call_summary(): - return await llm_with_structure.ainvoke( - [ - {"role": "system", "content": ATHLETE_MODEL_SYSTEM_PROMPT}, - {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}, - ], - config=invoke_config - or { - "run_name": "athlete_model_summary", - "tags": ["agent:athlete_model", "feature:coach_memory"], - }, - ) - - response = await retry_with_backoff(call_summary, QUICK_RETRY_CONFIG, "Athlete Model Summary") - payload = coerce_structured_output(response, AthleteModelSummary).model_dump(mode="json") - payload["transient_state_notes"] = _normalize_transient_state_notes(payload.get("transient_state_notes")) - confidence_rows = payload.get("confidence_by_field") or [] - normalized_confidence: list[dict[str, float | str]] = [] - for row in confidence_rows: - if not isinstance(row, dict): - continue - field_name = str(row.get("field_name", "")).strip() - if not field_name: - continue - raw_confidence = row.get("confidence") - if raw_confidence is None: - continue - try: - confidence_value = float(raw_confidence) - except (TypeError, ValueError): - continue - normalized_confidence.append( - { - "field_name": field_name, - "confidence": min(1.0, max(0.0, confidence_value)), - } - ) - payload["confidence_by_field"] = normalized_confidence - return AthleteModelSummary.model_validate(payload) + agent = build_head_coach_agent( + profile_name=RunProfileName.MEMORY_EXTRACTION, + response_schema=AthleteModelSummary, + tools=[], + task_instructions=ATHLETE_MODEL_SYSTEM_PROMPT, + name="athlete_model_summary", + ) + return await invoke_head_coach_agent( + agent=agent, + user_prompt=json.dumps( + {"previous_model": previous_model, "recent_events": recent_events}, + ensure_ascii=False, + default=str, + ), + response_schema=AthleteModelSummary, + invoke_config=invoke_config + or { + "run_name": "athlete_model_summary", + "tags": ["agent:athlete_model", "feature:coach_memory"], + }, + ) diff --git a/services/ai/coach/continuum_turn_agent.py b/services/ai/coach/continuum_turn_agent.py index 84cdbaf..8d66f3f 100644 --- a/services/ai/coach/continuum_turn_agent.py +++ b/services/ai/coach/continuum_turn_agent.py @@ -1,43 +1,51 @@ from __future__ import annotations +import inspect import json import logging import os from collections.abc import Callable from contextlib import nullcontext -from datetime import UTC, datetime -from typing import Protocol +from typing import Any from uuid import UUID +from langchain_core.messages import AIMessage, ToolMessage from langsmith.run_helpers import tracing_context from langsmith.run_trees import RunTree -from pydantic import BaseModel, Field - -from services.ai.ai_settings import AgentRole -from services.ai.coach.schemas import PlanPatchOp -from services.ai.langgraph.nodes.tool_calling_helper import handle_tool_calling_in_node -from services.ai.model_config import ModelSelector -from services.ai.utils.retry_handler import AI_ANALYSIS_CONFIG, retry_with_backoff +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from api.services.status_messages import tool_status_message +from services.ai.coach.schemas import AnyPlanPatchOp +from services.ai.head_coach.agent import build_head_coach_agent +from services.ai.head_coach.run_profiles import get_run_profile +from services.ai.head_coach.schemas import RunProfileName +from services.ai.head_coach.tool_policy import HeadCoachToolRegistry, build_profile_tools from services.ai.utils.structured_output import coerce_structured_output logger = logging.getLogger(__name__) -class CoachTurnToolRegistry(Protocol): - def create_langchain_tools(self) -> list: ... - - CoachTurnStatusEmitter = Callable[[dict[str, object]], object] class CoachTurnOutput(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + assistant_message: str = Field(..., min_length=1, max_length=4000) - proposal_ops: list[PlanPatchOp] = Field(default_factory=list) + proposal_ops: list[AnyPlanPatchOp] = Field(default_factory=list) requests_full_run: bool = Field(default=False) full_run_reason: str | None = Field(default=None, max_length=600) safety_flags: list[str] = Field(default_factory=list) requires_medical_disclaimer: bool = Field(default=False) + @model_validator(mode="after") + def validate_full_run_request(self) -> CoachTurnOutput: + if self.requests_full_run and not self.full_run_reason: + raise ValueError("full_run_reason is required when requests_full_run is true") + if not self.requests_full_run and self.full_run_reason: + raise ValueError("full_run_reason must be empty when requests_full_run is false") + return self + class CoachTurnTraceMetadata(BaseModel): project_name: str = Field(min_length=1, max_length=200) @@ -53,11 +61,8 @@ class CoachTurnExecution(BaseModel): trace_metadata: CoachTurnTraceMetadata | None = None -_TURN_SYSTEM_PROMPT = """\ -You are the athlete's long-term endurance coach. - -You are not a patch generator. You are a thoughtful coach who can optionally emit patch operations when needed. -Coach like a real human expert: specific, context-aware, accountable, and adaptive. +_TURN_INSTRUCTIONS = """\ +You are not a patch generator. Coach like a real human expert: specific, context-aware, accountable, and adaptive. Coaching Lens — internalize these as your professional instincts, not checklists: - Periodization awareness: know where the athlete is in their macro/meso/micro cycle. Advice that's perfect in base phase can be harmful in taper. @@ -72,17 +77,20 @@ class CoachTurnExecution(BaseModel): - Preserve continuity: explicitly connect today's recommendation to recent thread history when relevant. - Personalize to long_term_memory (athlete_model + memory_summary) and full current-thread events/tool traces. - Use structured `ui_context` from the app surface as the most specific request anchor when present. -- Read `evidence_profile` from the context pack before making claims. If its `claims_policy` says readiness or activity-completeness claims are unsupported, coach within those limits and acknowledge uncertainty when it matters. -- Treat subjective check-ins (sleep quality, soreness, motivation, available time, desk load, pain/illness status) as first-class evidence. Reconcile them with device data and recent execution rather than ignoring them or obeying them blindly. +- Treat subjective check-ins (sleep quality, soreness, motivation, available time, desk load, pain/illness status) as first-class athlete-declared evidence. Reconcile them with the local plan, coaching history, and declared constraints rather than inventing measurements. - Treat transient state notes (illness, acute pain, temporary constraints) as time-bounded context; verify if stale/uncertain before assuming they still apply. - Keep recommendations concrete, concise, and practical. - Hold accountability: highlight one priority action and one short check-in question when useful. - proposal_ops should be empty unless a plan adjustment is clearly warranted. +- Match proposal operations to `current_weekly_plan_identity.schema_version`: schema v1 uses the legacy operations; schema v3 uses only `update_day_fields_v3`, `update_session_fields_v3`, and `replace_semantic_block_v3`. +- Schema v3 content is Markdown and typed semantic blocks. Never emit raw HTML or CSS for schema v3. +- For schema v3, target the exact day_id, session_id, or semantic container/block IDs supplied by `current_weekly_plan_identity` or `get_current_weekly_plan`; never translate a v3 plan into legacy fields. +- When changing a schema-v3 session duration, the runtime derives the containing day's total duration from all sessions. Do not emit a separate `update_day_fields_v3.total_duration_min` merely to mirror that session change. - Strong readiness does not automatically mean extra intensity. If adding training is warranted, prefer low-risk easy volume, support work, timing changes, or desk-load movement before extra hard work, and protect the next key session. - If you reduce or skip sport-specific work, explain the tradeoff and whether the weekly volume floor or competition-specific preparation remains intact. - If a plan adjustment is clearly warranted, do not leave proposal_ops empty only because day/week identifiers are missing from the immediate context. Use `current_weekly_plan_identity` from the context pack when present, or call `get_current_weekly_plan` to obtain the relevant identifiers before deciding. -- If you emit proposal_ops that adjust a specific day, also emit an `update_day_fields` op for that day so the dashboard stays aligned (day_label, workout_title, focus_type/color, estimated_duration_min, estimated_intensity, readiness_note). Omit fields you are not changing. -- If you emit proposal_ops that change session content, keep the workout self-contained in the visible day blocks: include explicit intensity targets for each lap, rep, work block, recovery block, and cool-down whenever intensity changes. +- For schema v1 only, if you adjust a specific day, also emit an `update_day_fields` op so the legacy dashboard fields stay aligned. Omit fields you are not changing. +- Keep changed session content self-contained in the visible schema-appropriate workout or semantic blocks: include explicit intensity targets for each lap, rep, work block, recovery block, and cool-down whenever intensity changes. - Do not reduce interval guidance to a single overall zone label. The athlete should be able to open the calendar session and see the segment-by-segment intensity guidance immediately. - Creative challenge handling: propose or preserve weird, meaningful challenges only when they support the current phase. Avoid generic distance stunts and avoid adding challenge load that compromises imminent quality work. - Avoid deterministic formulaic coaching rules; reason from context and evidence instead. @@ -91,7 +99,7 @@ class CoachTurnExecution(BaseModel): - You have tool access for progressive disclosure. Start from existing context before calling tools. - Avoid duplicate tool calls when recent tool results already answer the question. - Use summary-level retrieval first, then deep detail only when uncertainty remains. -- If the evidence profile is incomplete, retrieve only what can reduce the real uncertainty; do not invent missing readiness or completeness certainty from thin data. +- Ask a focused follow-up when athlete-owned context cannot resolve a material uncertainty; never invent readiness metrics or completion evidence. - Respect tool_budget context and retrieve only what is necessary for high-quality coaching. - Tool names, descriptions, and argument schemas are provided by runtime tool metadata; rely on those contracts directly. @@ -107,11 +115,166 @@ class CoachTurnExecution(BaseModel): - If requests_full_run=true, full_run_reason must be non-empty. """ +_TOOL_TRACE_PREVIEW_CHARS = 4000 +_STRUCTURED_OUTPUT_TOOL_NAME = CoachTurnOutput.__name__ + def _build_user_prompt(*, user_message: str, context_pack: dict) -> str: return f"Athlete message:\n{user_message}\n\nContext pack JSON:\n{json.dumps(context_pack, ensure_ascii=False)}\n" +def _serialize_message_content(content: object) -> str: + if isinstance(content, str): + return content + try: + return json.dumps(content, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(content) + + +async def _emit_status( + status_emitter: CoachTurnStatusEmitter | None, + payload: dict[str, object], +) -> None: + if status_emitter is None: + return + result = status_emitter(payload) + if inspect.isawaitable(result): + await result + + +async def _record_ai_tool_calls( + *, + message: AIMessage, + iteration: int, + tool_calls: dict[str, dict[str, object]], + status_emitter: CoachTurnStatusEmitter | None, +) -> None: + for call in message.tool_calls: + call_id = str(call.get("id", "")) + tool_name = str(call.get("name", "")) + if tool_name == _STRUCTURED_OUTPUT_TOOL_NAME: + continue + args = call.get("args") if isinstance(call.get("args"), dict) else {} + tool_calls[call_id] = {"tool_name": tool_name, "args": args} + await _emit_status( + status_emitter, + { + "step": "tool_call_start", + "tool_name": tool_name, + "message": tool_status_message(tool_name, args), + "iteration": iteration, + }, + ) + + +async def _record_tool_result( + *, + message: ToolMessage, + iteration: int, + tool_calls: dict[str, dict[str, object]], + tool_traces: list[dict[str, object]], + status_emitter: CoachTurnStatusEmitter | None, +) -> None: + if message.name == _STRUCTURED_OUTPUT_TOOL_NAME: + return + content = _serialize_message_content(message.content) + preview = content[:_TOOL_TRACE_PREVIEW_CHARS] + call = tool_calls.get(str(message.tool_call_id), {}) + tool_name = str(call.get("tool_name") or message.name or "unknown_tool") + tool_traces.append( + { + "tool_name": tool_name, + "args": call.get("args", {}), + "result_preview": preview, + "char_len": len(content), + "truncated": len(content) > len(preview), + } + ) + await _emit_status( + status_emitter, + { + "step": "tool_call_end", + "tool_name": tool_name, + "message": "Applying the latest retrieved context...", + "iteration": max(iteration, 1), + }, + ) + + +async def _process_agent_update( + *, + update: object, + iteration: int, + tool_calls: dict[str, dict[str, object]], + tool_traces: list[dict[str, object]], + status_emitter: CoachTurnStatusEmitter | None, +) -> tuple[object | None, int]: + if not isinstance(update, dict): + return None, iteration + structured_response: object | None = None + for node_update in update.values(): + if not isinstance(node_update, dict): + continue + if node_update.get("structured_response") is not None: + structured_response = node_update["structured_response"] + messages = node_update.get("messages", []) + for message in messages if isinstance(messages, list) else [messages]: + if isinstance(message, AIMessage): + iteration += 1 + await _record_ai_tool_calls( + message=message, + iteration=iteration, + tool_calls=tool_calls, + status_emitter=status_emitter, + ) + elif isinstance(message, ToolMessage): + await _record_tool_result( + message=message, + iteration=iteration, + tool_calls=tool_calls, + tool_traces=tool_traces, + status_emitter=status_emitter, + ) + return structured_response, iteration + + +async def _run_agent_stream( + *, + agent, + user_prompt: str, + invoke_config: dict[str, Any], + status_emitter: CoachTurnStatusEmitter | None, +) -> tuple[CoachTurnOutput, list[dict[str, object]]]: + tool_calls: dict[str, dict[str, object]] = {} + tool_traces: list[dict[str, object]] = [] + structured_response: object | None = None + iteration = 0 + + await _emit_status( + status_emitter, + {"step": "thinking", "message": "Thinking through the next best step...", "iteration": 1}, + ) + async for update in agent.astream( + {"messages": [{"role": "user", "content": user_prompt}]}, + config=invoke_config, + stream_mode="updates", + ): + candidate, iteration = await _process_agent_update( + update=update, + iteration=iteration, + tool_calls=tool_calls, + tool_traces=tool_traces, + status_emitter=status_emitter, + ) + if candidate is not None: + structured_response = candidate + + if structured_response is None: + raise RuntimeError("Head Coach turn completed without a structured response") + return coerce_structured_output(structured_response, CoachTurnOutput), tool_traces + + def _langsmith_project_name() -> str | None: if not os.getenv("LANGSMITH_API_KEY"): return None @@ -194,16 +357,21 @@ async def run_continuum_coach_turn( *, user_message: str, context_pack: dict, - tool_registry: CoachTurnToolRegistry | None, + tool_registry: HeadCoachToolRegistry | None, thread_id: str | None = None, user_id: str | None = None, status_emitter: CoachTurnStatusEmitter | None = None, root_run_id: str | None = None, ) -> CoachTurnExecution: - base_llm = ModelSelector.get_llm(AgentRole.COACH) - tools = tool_registry.create_langchain_tools() if tool_registry else [] - llm_with_tools = base_llm.bind_tools(tools) if tools else base_llm - llm_with_structure = base_llm.with_structured_output(CoachTurnOutput) + profile = get_run_profile(RunProfileName.COACH_TURN) + tools = build_profile_tools(profile, tool_registry=tool_registry) + agent = build_head_coach_agent( + profile_name=RunProfileName.COACH_TURN, + response_schema=CoachTurnOutput, + tools=tools, + task_instructions=_TURN_INSTRUCTIONS, + name="continuum_coach_turn", + ) tags = ["agent:continuum_coach_turn", "feature:coach_turn"] if user_id: tags.append(f"user:{user_id}") @@ -230,50 +398,19 @@ async def run_continuum_coach_turn( if root_run is not None else nullcontext() ) - attempt_count = 0 - - async def call_turn() -> tuple[CoachTurnOutput, list[dict[str, object]]]: - nonlocal attempt_count - attempt_count += 1 - tool_traces: list[dict[str, object]] = [] - - def _collect_tool_trace(trace_payload: dict[str, object]): - tool_traces.append(trace_payload) - - if root_run is not None and attempt_count > 1: - root_run.add_event( - { - "name": "retry_attempt", - "time": datetime.now(UTC).isoformat(), - "message": f"Retry attempt {attempt_count}", - } - ) - - response = await handle_tool_calling_in_node( - llm_with_tools=llm_with_tools, - messages=[ - {"role": "system", "content": _TURN_SYSTEM_PROMPT}, - {"role": "user", "content": _build_user_prompt(user_message=user_message, context_pack=context_pack)}, - ], - tools=tools, - max_iterations=10, - final_output_llm=llm_with_structure, - invoke_config={ - "run_name": "continuum_coach_turn", - "tags": tags, - "metadata": { - "thread_id": thread_id, - "user_id": user_id, - }, - }, - tool_trace_collector=_collect_tool_trace, - status_emitter=status_emitter, - ) - return coerce_structured_output(response, CoachTurnOutput), tool_traces - + attempt_count = 1 with trace_context: try: - output, tool_traces = await retry_with_backoff(call_turn, AI_ANALYSIS_CONFIG, "Continuum Coach Turn") + output, tool_traces = await _run_agent_stream( + agent=agent, + user_prompt=_build_user_prompt(user_message=user_message, context_pack=context_pack), + invoke_config={ + "run_name": "continuum_coach_turn", + "tags": tags, + "metadata": {"thread_id": thread_id, "user_id": user_id}, + }, + status_emitter=status_emitter, + ) except Exception as exc: _finish_root_turn_trace(root_run, attempt_count=attempt_count, error=exc) raise diff --git a/services/ai/coach/patch_apply.py b/services/ai/coach/patch_apply.py index aeaedee..70d8f80 100644 --- a/services/ai/coach/patch_apply.py +++ b/services/ai/coach/patch_apply.py @@ -7,10 +7,14 @@ DeleteWeekNotesBlockOp, PatchApplicationResult, ReplaceDayBlocksOp, + ReplaceV3SemanticBlockOp, UpdateDayFieldsOp, + UpdateV3DayFieldsOp, + UpdateV3SessionFieldsOp, UpsertDayBlockOp, UpsertWeekNotesBlockOp, ) +from services.ai.head_coach.artifacts import ExecutionPlanArtifactV3 from services.ai.langgraph.schemas.ui_blocks import UiDisclosureNode, UiHtmlBlock, UiWeeklyPlan _DAY_FIELD_NAMES = ( @@ -23,6 +27,19 @@ "readiness_note", ) +_V3_DAY_FIELD_NAMES = ("label", "focus_type", "intensity", "total_duration_min") +_V3_SESSION_FIELD_NAMES = ( + "title", + "objective_markdown", + "prescription_markdown", + "duration_min", + "intensity", + "distance_km", +) +_V3_REQUIRED_SESSION_FIELD_NAMES = frozenset( + {"title", "objective_markdown", "prescription_markdown", "duration_min", "intensity"} +) + def _flatten_node_blocks(nodes: list[UiDisclosureNode]) -> list[UiHtmlBlock]: out: list[UiHtmlBlock] = [] @@ -57,7 +74,7 @@ def _materialize_day_blocks(*, blocks: list[UiHtmlBlock], nodes: list[UiDisclosu return merged -def apply_update_day_fields(plan: UiWeeklyPlan, op: UpdateDayFieldsOp) -> PatchApplicationResult: +def apply_update_day_fields(plan: UiWeeklyPlan, op: UpdateDayFieldsOp) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) provided_fields = [field for field in _DAY_FIELD_NAMES if field in op.model_fields_set] @@ -84,7 +101,7 @@ def apply_update_day_fields(plan: UiWeeklyPlan, op: UpdateDayFieldsOp) -> PatchA return PatchApplicationResult(updated_plan=updated, changed=False) -def apply_delete_day_block(plan: UiWeeklyPlan, op: DeleteDayBlockOp) -> PatchApplicationResult: +def apply_delete_day_block(plan: UiWeeklyPlan, op: DeleteDayBlockOp) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) for week in updated.weeks: @@ -108,7 +125,7 @@ def apply_delete_day_block(plan: UiWeeklyPlan, op: DeleteDayBlockOp) -> PatchApp return PatchApplicationResult(updated_plan=updated, changed=False) -def apply_replace_day_blocks(plan: UiWeeklyPlan, op: ReplaceDayBlocksOp) -> PatchApplicationResult: +def apply_replace_day_blocks(plan: UiWeeklyPlan, op: ReplaceDayBlocksOp) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) for week in updated.weeks: @@ -128,7 +145,7 @@ def apply_replace_day_blocks(plan: UiWeeklyPlan, op: ReplaceDayBlocksOp) -> Patc return PatchApplicationResult(updated_plan=updated, changed=False) -def apply_upsert_day_block(plan: UiWeeklyPlan, op: UpsertDayBlockOp) -> PatchApplicationResult: +def apply_upsert_day_block(plan: UiWeeklyPlan, op: UpsertDayBlockOp) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) for week in updated.weeks: @@ -161,7 +178,9 @@ def apply_upsert_day_block(plan: UiWeeklyPlan, op: UpsertDayBlockOp) -> PatchApp return PatchApplicationResult(updated_plan=updated, changed=False) -def apply_upsert_week_notes_block(plan: UiWeeklyPlan, op: UpsertWeekNotesBlockOp) -> PatchApplicationResult: +def apply_upsert_week_notes_block( + plan: UiWeeklyPlan, op: UpsertWeekNotesBlockOp +) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) for idx, week in enumerate(updated.weeks): @@ -196,7 +215,10 @@ def apply_upsert_week_notes_block(plan: UiWeeklyPlan, op: UpsertWeekNotesBlockOp return PatchApplicationResult(updated_plan=updated, changed=False) -def apply_delete_week_notes_block(plan: UiWeeklyPlan, op: DeleteWeekNotesBlockOp) -> PatchApplicationResult: +def apply_delete_week_notes_block( + plan: UiWeeklyPlan, + op: DeleteWeekNotesBlockOp, +) -> PatchApplicationResult[UiWeeklyPlan]: updated = copy.deepcopy(plan) for idx, week in enumerate(updated.weeks): @@ -218,3 +240,83 @@ def apply_delete_week_notes_block(plan: UiWeeklyPlan, op: DeleteWeekNotesBlockOp return PatchApplicationResult(updated_plan=updated, changed=True) return PatchApplicationResult(updated_plan=updated, changed=False) + + +def apply_update_v3_day_fields( + plan: ExecutionPlanArtifactV3, + op: UpdateV3DayFieldsOp, +) -> PatchApplicationResult[ExecutionPlanArtifactV3]: + payload = plan.model_dump(mode="python") + update = {name: getattr(op, name) for name in _V3_DAY_FIELD_NAMES if name in op.model_fields_set} + if not update or all(value is None for value in update.values()): + return PatchApplicationResult(updated_plan=plan, changed=False) + + for week in payload["weeks"]: + for day in week["days"]: + if day["day_id"] != op.day_id: + continue + day.update({name: value for name, value in update.items() if value is not None}) + updated = ExecutionPlanArtifactV3.model_validate(payload) + return PatchApplicationResult(updated_plan=updated, changed=updated != plan) + return PatchApplicationResult(updated_plan=plan, changed=False) + + +def apply_update_v3_session_fields( + plan: ExecutionPlanArtifactV3, + op: UpdateV3SessionFieldsOp, +) -> PatchApplicationResult[ExecutionPlanArtifactV3]: + payload = plan.model_dump(mode="python") + update: dict[str, object] = {} + for name in _V3_SESSION_FIELD_NAMES: + if name not in op.model_fields_set: + continue + value = getattr(op, name) + if value is None and name in _V3_REQUIRED_SESSION_FIELD_NAMES: + continue + update[name] = value + if not update: + return PatchApplicationResult(updated_plan=plan, changed=False) + + for week in payload["weeks"]: + for day in week["days"]: + for session in day["sessions"]: + if session["session_id"] != op.session_id: + continue + session.update(update) + day["total_duration_min"] = sum(item["duration_min"] for item in day["sessions"]) + updated = ExecutionPlanArtifactV3.model_validate(payload) + return PatchApplicationResult(updated_plan=updated, changed=updated != plan) + return PatchApplicationResult(updated_plan=plan, changed=False) + + +def apply_replace_v3_semantic_block( + plan: ExecutionPlanArtifactV3, + op: ReplaceV3SemanticBlockOp, +) -> PatchApplicationResult[ExecutionPlanArtifactV3]: + payload = plan.model_dump(mode="python") + containers = _v3_block_containers(payload) + container = containers.get(op.container_id) + if container is None: + return PatchApplicationResult(updated_plan=plan, changed=False) + + for index, block in enumerate(container["blocks"]): + if block["block_id"] != op.block_id: + continue + replacement = op.block.model_dump(mode="python") + if block == replacement: + return PatchApplicationResult(updated_plan=plan, changed=False) + container["blocks"][index] = replacement + updated = ExecutionPlanArtifactV3.model_validate(payload) + return PatchApplicationResult(updated_plan=updated, changed=True) + return PatchApplicationResult(updated_plan=plan, changed=False) + + +def _v3_block_containers(payload: dict) -> dict[str, dict]: + containers = {f"section:{section['section_id']}": section for section in payload["sections"]} + for week in payload["weeks"]: + containers[f"week:{week['week_id']}"] = week + for day in week["days"]: + containers[f"day:{day['day_id']}"] = day + for session in day["sessions"]: + containers[f"session:{session['session_id']}"] = session + return containers diff --git a/services/ai/coach/plan_modifier_agent.py b/services/ai/coach/plan_modifier_agent.py deleted file mode 100644 index 4ae0a2c..0000000 --- a/services/ai/coach/plan_modifier_agent.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import json -from typing import Protocol - -from pydantic import BaseModel, Field - -from services.ai.ai_settings import AgentRole -from services.ai.coach.schemas import CoachResponse, PlanPatchOp -from services.ai.langgraph.nodes.tool_calling_helper import handle_tool_calling_in_node -from services.ai.langgraph.schemas.ui_blocks import UiWeeklyPlan -from services.ai.model_config import ModelSelector -from services.ai.utils.retry_handler import AI_ANALYSIS_CONFIG, retry_with_backoff - - -class _CoachStructuredOutput(BaseModel): - assistant_message: str = Field(..., description="Short coach response to the user") - ops: list[PlanPatchOp] = Field(default_factory=list) - - -class CoachToolRegistry(Protocol): - def create_langchain_tools(self) -> list: - ... - - def get_observability_snapshot(self) -> dict: - ... - - -SYSTEM_PROMPT = """You are a coaching assistant that modifies a training plan. - -Rules: -- You MUST return ONLY structured JSON matching the schema. -- Prefer emitting small patch operations instead of rewriting the whole plan. -- You may modify week plan only using patch ops: - - day blocks (upsert/delete/replace) - - day fields (update_day_fields: day_label, workout_title, focus_type/color, estimated_duration_min, estimated_intensity, readiness_note) - - week notes blocks (upsert/delete) -- When you change a day's session content, also emit an update_day_fields op for the same day to keep dashboard metadata aligned. -- When you add or replace a workout session, keep the session self-contained: show explicit intensity targets for each warm-up, lap, rep, work block, recovery block, and cool-down whenever intensity changes. -- Do not collapse interval guidance into only a title or one overall zone label. The athlete should be able to open the calendar session and see the target zone for each segment directly in the day blocks. -- Use tools to fetch only the context you need. -- Use stable block keys; keys are unique within each container. -- Do not include "} + repair = AsyncMock(return_value=valid) + + artifact = await validate_artifact_with_repair( + invalid, + artifact_type="season_plan", + repair=repair, + ) + + assert artifact.plan_id == "season-repaired" + assert repair.await_args is not None + request = repair.await_args.args[0] + assert request["rejected_output"] == invalid + assert request["validation_errors"] + assert "fallback" not in request + + +@pytest.mark.asyncio +async def test_exhausted_head_coach_artifact_repair_fails_without_replacement(): + invalid = {"type": "weekly_plan", "schema_version": 3, "title": "Incomplete"} + repair = AsyncMock(return_value=invalid) + + with pytest.raises(ArtifactValidationError, match="repair budget exhausted"): + await validate_artifact_with_repair( + invalid, + artifact_type="weekly_plan", + repair=repair, + ) + + repair.assert_awaited_once() diff --git a/tests/test_head_coach_checkpoint_retention.py b/tests/test_head_coach_checkpoint_retention.py new file mode 100644 index 0000000..19a34f2 --- /dev/null +++ b/tests/test_head_coach_checkpoint_retention.py @@ -0,0 +1,76 @@ +import asyncio +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from services.ai.head_coach.checkpointing import ( + InMemoryExecutionClaimManager, + RunAlreadyClaimedError, + delete_checkpoint_thread, + derive_advisory_lock_key, +) +from services.ai.head_coach.middleware import LifecyclePhase, build_lifecycle_event + +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") + + +def test_advisory_lock_key_is_stable_signed_bigint(): + first = derive_advisory_lock_key("owner:one:analysis:run") + second = derive_advisory_lock_key("owner:one:analysis:run") + + assert first == second + assert -(2**63) <= first < 2**63 + + +@pytest.mark.asyncio +async def test_overlapping_execution_claims_fail_closed_and_release_after_owner_exits(): + manager = InMemoryExecutionClaimManager() + first_claim_entered = asyncio.Event() + release_first_claim = asyncio.Event() + + async def hold_first_claim() -> None: + async with manager.claim("run-1"): + first_claim_entered.set() + await release_first_claim.wait() + + first_task = asyncio.create_task(hold_first_claim()) + await first_claim_entered.wait() + + with pytest.raises(RunAlreadyClaimedError): + async with manager.claim("run-1"): + pass + + release_first_claim.set() + await first_task + + async with manager.claim("run-1"): + pass + + +def test_lifecycle_event_excludes_raw_context_and_reasoning(): + event = build_lifecycle_event( + phase=LifecyclePhase.REVIEWING_CONSTRAINTS, + run_id="run-1", + profile_name="initial_planning", + occurred_at=datetime.now(UTC) - timedelta(seconds=1), + artifact_ids=["artifact-1"], + ) + payload = event.model_dump(mode="json") + + assert payload["phase"] == "reviewing_constraints" + assert "messages" not in payload + assert "context" not in payload + assert "reasoning" not in payload + assert "credentials" not in payload + + +@pytest.mark.asyncio +async def test_checkpoint_cleanup_targets_one_execution_thread(): + db = AsyncMock() + db.execute.return_value.rowcount = 2 + + thread_deleted = await delete_checkpoint_thread(db, thread_id="owner:one:analysis:job") + assert thread_deleted == 6 + assert db.execute.await_count == 3 diff --git a/tests/test_head_coach_checkpointing.py b/tests/test_head_coach_checkpointing.py new file mode 100644 index 0000000..fe64dc0 --- /dev/null +++ b/tests/test_head_coach_checkpointing.py @@ -0,0 +1,122 @@ +from importlib.metadata import version +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.postgres.base import MIGRATIONS + +from services.ai.head_coach.checkpointing import ( + CHECKPOINT_SCHEMA_VERSION, + CHECKPOINT_TABLES, + CheckpointScope, + CheckpointUnavailableError, + HeadCoachCheckpointerProvider, + build_checkpoint_config, + build_checkpoint_identity, + delete_owner_checkpoints, + normalize_checkpoint_database_url, +) + +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") +RUN_ID = UUID("00000000-0000-0000-0000-000000000002") + + +def test_postgres_checkpointer_dependency_and_schema_version_are_pinned_together(): + assert version("langgraph-checkpoint-postgres") == "3.1.0" + assert CHECKPOINT_SCHEMA_VERSION == len(MIGRATIONS) - 1 == 9 + assert CHECKPOINT_TABLES == { + "checkpoint_migrations", + "checkpoints", + "checkpoint_blobs", + "checkpoint_writes", + } + + +@pytest.mark.parametrize( + ("database_url", "expected"), + [ + ( + "postgresql+asyncpg://postgres:secret@localhost:5432/paced_coach", + "postgresql://postgres:secret@localhost:5432/paced_coach", + ), + ( + "postgres://postgres:secret@localhost:5432/paced_coach", + "postgresql://postgres:secret@localhost:5432/paced_coach", + ), + ( + "postgresql://postgres:secret@localhost:5432/paced_coach?sslmode=disable", + "postgresql://postgres:secret@localhost:5432/paced_coach?sslmode=disable", + ), + ], +) +def test_checkpoint_database_url_uses_psycopg_scheme(database_url: str, expected: str): + assert normalize_checkpoint_database_url(database_url) == expected + + +def test_checkpoint_identity_is_stable_owner_scoped_and_namespaced(): + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + + assert identity.thread_id == f"owner:{OWNER_ID}:analysis:{RUN_ID}" + assert identity.checkpoint_ns == "" + assert build_checkpoint_config(identity) == { + "configurable": { + "thread_id": identity.thread_id, + "checkpoint_ns": identity.checkpoint_ns, + } + } + + coach_identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.COACH_TURN, + resource_id="thread-1", + execution_id=RUN_ID, + ) + assert coach_identity.thread_id == f"owner:{OWNER_ID}:coach:thread-1:scope:coach_turn:v1:run:{RUN_ID}" + + +@pytest.mark.asyncio +async def test_injected_test_checkpointer_never_opens_postgres(): + checkpointer = InMemorySaver() + provider = HeadCoachCheckpointerProvider( + database_url="postgresql://unused", + injected_checkpointer=checkpointer, + ) + + assert await provider.get() is checkpointer + await provider.close() + + +@pytest.mark.asyncio +async def test_production_checkpointer_unavailability_fails_without_memory_fallback(): + provider = HeadCoachCheckpointerProvider( + database_url="postgresql://postgres:postgres@127.0.0.1:1/unavailable", + pool_timeout_seconds=0.05, + ) + + with pytest.raises(CheckpointUnavailableError, match="no in-memory fallback"): + await provider.get() + + await provider.close() + + +@pytest.mark.asyncio +async def test_owner_checkpoint_deletion_covers_every_payload_table(): + db = SimpleNamespace( + execute=AsyncMock(return_value=cast("Any", type("Result", (), {"rowcount": 2})())) + ) + + deleted = await delete_owner_checkpoints(cast("Any", db), owner_id=OWNER_ID) + + statements = [str(call.args[0]) for call in db.execute.await_args_list] + assert any("checkpoint_writes" in statement for statement in statements) + assert any("checkpoint_blobs" in statement for statement in statements) + assert any("checkpoints" in statement for statement in statements) + assert all(call.args[1]["thread_prefix"] == f"owner:{OWNER_ID}:%" for call in db.execute.await_args_list) + assert deleted == 6 diff --git a/tests/test_head_coach_contracts.py b/tests/test_head_coach_contracts.py new file mode 100644 index 0000000..2a37897 --- /dev/null +++ b/tests/test_head_coach_contracts.py @@ -0,0 +1,120 @@ +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from api.services.coach_context import package_head_coach_brief +from services.ai.ai_settings import AgentRole +from services.ai.head_coach.prompts import build_head_coach_system_prompt +from services.ai.head_coach.run_profiles import ( + MutationAuthority, + RunProfileName, + get_run_profile, +) +from services.ai.head_coach.schemas import ( + EvidenceAuthority, + EvidenceProvenance, + EvidenceSourceKind, + HeadCoachBrief, +) + + +@pytest.mark.parametrize("profile_name", list(RunProfileName)) +def test_every_supported_run_profile_uses_an_explicit_semantic_configuration(profile_name): + profile = get_run_profile(profile_name) + + assert profile.name is profile_name + assert profile.model_role in { + AgentRole.HEAD_COACH, + AgentRole.MEMORY, + AgentRole.SPECIALIST, + AgentRole.UI_COMPOSER, + } + assert profile.reasoning_effort in {"low", "medium", "high", "xhigh"} + + +def test_reasoning_and_authority_follow_task_semantics(): + initial = get_run_profile(RunProfileName.INITIAL_PLANNING) + replan = get_run_profile(RunProfileName.MATERIAL_REPLANNING) + coach_turn = get_run_profile(RunProfileName.COACH_TURN) + recap = get_run_profile(RunProfileName.WEEKLY_RECAP) + composer = get_run_profile(RunProfileName.UI_COMPOSER) + + assert initial.reasoning_effort == "medium" + assert initial.mutation_authority is MutationAuthority.INITIAL_COMMIT + assert replan.reasoning_effort == "xhigh" + assert replan.mutation_authority is MutationAuthority.PROPOSE + assert coach_turn.reasoning_effort == "medium" + assert coach_turn.mutation_authority is MutationAuthority.PROPOSE + assert recap.reasoning_effort == "high" + assert recap.mutation_authority is MutationAuthority.PROPOSE + assert composer.reasoning_effort == "low" + assert composer.mutation_authority is MutationAuthority.NONE + + +def test_only_research_specialist_enables_native_web_search(): + enabled_profiles = { + profile_name for profile_name in RunProfileName if get_run_profile(profile_name).enable_native_web_search + } + + assert enabled_profiles == {RunProfileName.RESEARCH_SPECIALIST} + + +def test_unknown_run_profile_fails_before_model_selection(): + with pytest.raises(ValueError, match="Unsupported Head Coach run profile"): + get_run_profile("magic_mode") + + +def test_head_coach_brief_preserves_full_local_context_and_serializes(): + context_pack = { + "now_utc": "2026-07-19T08:30:00+00:00", + "athlete_model": {"experience": "advanced", "nested": {"keep": [1, 2, 3]}}, + "upcoming_competitions": [{"name": "Synthetic A race", "priority": "A"}], + "current_weekly_plan_identity": {"plan_id": "plan-1", "version": 4}, + "evidence_profile": {"claims_policy": {"readiness": "unsupported"}}, + } + + brief = package_head_coach_brief( + owner_id="owner-1", + run_id="run-1", + profile_name=RunProfileName.COACH_TURN, + context_pack=context_pack, + ) + + assert brief.as_of_utc == datetime(2026, 7, 19, 8, 30, tzinfo=UTC) + assert brief.local_context == context_pack + assert brief.model_dump(mode="json")["local_context"] == context_pack + assert "database" not in brief.model_dump_json() + + +def test_head_coach_brief_rejects_an_untyped_profile_name(): + with pytest.raises(ValidationError, match="profile_name"): + HeadCoachBrief.model_validate( + { + "owner_id": "owner-1", + "run_id": "run-1", + "profile_name": "magic_mode", + "as_of_utc": datetime(2026, 7, 19, 8, 30, tzinfo=UTC), + "local_context": {}, + } + ) + + +def test_consultative_evidence_cannot_claim_athlete_declared_authority(): + with pytest.raises(ValidationError, match="athlete-declared"): + EvidenceProvenance( + source_kind=EvidenceSourceKind.SPECIALIST, + authority=EvidenceAuthority.ATHLETE_DECLARED, + source_id="specialist:training-research", + ) + + +def test_prompt_keeps_one_identity_and_scopes_task_instructions(): + planning_prompt = build_head_coach_system_prompt(get_run_profile(RunProfileName.INITIAL_PLANNING)) + coach_prompt = build_head_coach_system_prompt(get_run_profile(RunProfileName.COACH_TURN)) + + assert planning_prompt.startswith("You are the athlete's persistent Head Coach.") + assert coach_prompt.startswith("You are the athlete's persistent Head Coach.") + assert "Season Strategy" in planning_prompt + assert "proposal intent" in coach_prompt + assert "Do not invent wearable" in planning_prompt diff --git a/tests/test_head_coach_eval_contracts.py b/tests/test_head_coach_eval_contracts.py new file mode 100644 index 0000000..972a239 --- /dev/null +++ b/tests/test_head_coach_eval_contracts.py @@ -0,0 +1,139 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from services.ai.evals.head_coach_eval import ( + CandidateRunMetrics, + ComparisonOutcome, + HardInvariantResult, + HeadCoachEvalSuite, + ScenarioComparison, + evaluate_release_gates, + load_eval_suite, +) + +FIXTURE_PATH = Path("tests/fixtures/head_coach_eval_cases.json") + + +def _passing_invariants() -> list[HardInvariantResult]: + suite = load_eval_suite(FIXTURE_PATH) + return [ + HardInvariantResult(scenario_id=case.scenario_id, invariant=invariant, passed=True) + for case in suite.cases + for invariant in case.required_invariants + ] + + +def _comparisons(*, baseline_wins: int = 0) -> list[ScenarioComparison]: + suite = load_eval_suite(FIXTURE_PATH) + return [ + ScenarioComparison( + scenario_id=case.scenario_id, + outcome=(ComparisonOutcome.BASELINE_WINS if index < baseline_wins else ComparisonOutcome.CANDIDATE_WINS), + ) + for index, case in enumerate(suite.cases) + ] + + +def test_eval_fixture_covers_release_critical_provider_free_scenarios(): + suite = load_eval_suite(FIXTURE_PATH) + + assert suite.metadata.synthetic_only is True + assert suite.baseline.mandatory_model_calls == 13 + assert len(suite.baseline.mandatory_model_nodes) == 13 + assert {case.category for case in suite.cases} == { + "conflicting_availability", + "experienced_constraints", + "material_plan_change", + "missed_week", + "optional_evidence_unavailable", + "pain_or_illness", + "sparse_beginner", + "stale_plan", + } + assert all(case.synthetic for case in suite.cases) + + +def test_eval_suite_model_is_immutable(): + suite: HeadCoachEvalSuite = load_eval_suite(FIXTURE_PATH) + + with pytest.raises(ValidationError, match="frozen"): + suite.baseline.mandatory_model_calls = 1 + + +def test_eval_fixture_rejects_non_synthetic_cases(tmp_path): + payload = FIXTURE_PATH.read_text(encoding="utf-8").replace( + '"synthetic": true', + '"synthetic": false', + 1, + ) + fixture_path = tmp_path / "unsafe_eval_cases.json" + fixture_path.write_text(payload, encoding="utf-8") + + with pytest.raises(ValidationError, match="synthetic"): + load_eval_suite(fixture_path) + + +def test_release_gate_passes_for_quality_preserving_lower_call_candidate(): + suite = load_eval_suite(FIXTURE_PATH) + candidate = CandidateRunMetrics( + mandatory_model_calls=6, + dedicated_deep_reasoning_formatter_calls=0, + provider_pipeline_calls=0, + ) + + report = evaluate_release_gates( + suite, + candidate=candidate, + comparisons=_comparisons(baseline_wins=1), + invariant_results=_passing_invariants(), + ) + + assert report.passed is True + assert report.call_reduction > 0.5 + assert report.equal_or_better_rate == pytest.approx(0.875) + assert report.failed_gates == [] + + +def test_release_gate_reports_each_failed_architecture_or_quality_gate(): + suite = load_eval_suite(FIXTURE_PATH) + candidate = CandidateRunMetrics( + mandatory_model_calls=7, + dedicated_deep_reasoning_formatter_calls=1, + provider_pipeline_calls=1, + ) + invariants = _passing_invariants() + invariants[0] = invariants[0].model_copy(update={"passed": False}) + + report = evaluate_release_gates( + suite, + candidate=candidate, + comparisons=_comparisons(baseline_wins=2), + invariant_results=invariants, + ) + + assert report.passed is False + assert set(report.failed_gates) == { + "hard_invariants", + "mandatory_model_call_reduction", + "pairwise_quality", + "provider_free_pipeline", + "reasoning_formatter_elimination", + } + + +def test_release_gate_requires_complete_scenario_evidence(): + suite = load_eval_suite(FIXTURE_PATH) + + with pytest.raises(ValueError, match="Missing comparison results"): + evaluate_release_gates( + suite, + candidate=CandidateRunMetrics( + mandatory_model_calls=6, + dedicated_deep_reasoning_formatter_calls=0, + provider_pipeline_calls=0, + ), + comparisons=_comparisons()[:-1], + invariant_results=_passing_invariants(), + ) diff --git a/tests/test_head_coach_initial_planning.py b/tests/test_head_coach_initial_planning.py new file mode 100644 index 0000000..ae4a497 --- /dev/null +++ b/tests/test_head_coach_initial_planning.py @@ -0,0 +1,343 @@ +from datetime import UTC, date, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from services.ai.head_coach.artifacts import ( + DecisionLedgerEntry, + ExecutionDay, + ExecutionPlanArtifactV3, + ExecutionWeek, + SeasonPhase, + SeasonStrategyArtifactV3, + TrainingSession, +) +from services.ai.head_coach.checkpointing import HeadCoachCheckpointerProvider +from services.ai.head_coach.initial_planning import ( + PlanningStrategy, + StrategyPlanningDecisionEnvelope, + build_initial_planning_context, + build_model_planner, + run_initial_planning, +) +from services.ai.head_coach.middleware import LifecyclePhase + +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") +JOB_ID = UUID("00000000-0000-0000-0000-000000000002") + + +def _decision() -> DecisionLedgerEntry: + return DecisionLedgerEntry( + decision_id="initial-decision", + decided_at=datetime(2026, 8, 3, 9, tzinfo=UTC), + title="Consistency first", + rationale_markdown="Repeatable training creates the foundation.", + changes_markdown="Created the initial strategy and execution block.", + ) + + +def _artifacts() -> tuple[SeasonStrategyArtifactV3, ExecutionPlanArtifactV3]: + start = date(2026, 8, 3) + season = SeasonStrategyArtifactV3( + plan_id="season-initial", + version=1, + athlete_name="Sample Athlete", + created_at=datetime(2026, 8, 3, 9, tzinfo=UTC), + title="Autumn foundation", + summary_markdown="Build repeatability before specificity.", + start_date=start, + end_date=date(2026, 11, 1), + phases=[ + SeasonPhase( + phase_id="foundation", + title="Foundation", + start_date=start, + end_date=date(2026, 8, 30), + objective_markdown="Establish a repeatable rhythm.", + ) + ], + decision_ledger_entry=_decision(), + ) + weeks = [] + for week_index in range(4): + week_start = start + timedelta(days=week_index * 7) + days = [] + for day_index in range(7): + day_date = week_start + timedelta(days=day_index) + rest = day_index in {2, 6} + days.append( + ExecutionDay( + day_id=f"day-{day_date.isoformat()}", + date=day_date, + label=day_date.strftime("%A"), + focus_type="rest" if rest else "aerobic", + intensity="rest" if rest else "low", + total_duration_min=0 if rest else 40, + sessions=[] + if rest + else [ + TrainingSession( + session_id=f"session-{day_date.isoformat()}", + title="Easy aerobic run", + sport="running", + objective_markdown="Build calm aerobic frequency.", + prescription_markdown="Run conversationally and finish with reserve.", + duration_min=40, + intensity="low", + ) + ], + ) + ) + weeks.append( + ExecutionWeek( + week_id=f"week-{week_index + 1}", + title=f"Week {week_index + 1}", + start_date=week_start, + end_date=week_start + timedelta(days=6), + intent_markdown="Protect repeatability.", + days=days, + ) + ) + execution = ExecutionPlanArtifactV3( + plan_id="execution-initial", + season_plan_id=season.plan_id, + version=1, + athlete_name=season.athlete_name, + created_at=season.created_at, + title="First 28 days", + summary_markdown="Keep the easy work easy.", + start_date=start, + end_date=start + timedelta(days=27), + weeks=weeks, + decision_ledger_entry=_decision(), + ) + return season, execution + + +def _context() -> dict: + return build_initial_planning_context( + now_utc=datetime(2026, 7, 19, 8, tzinfo=UTC), + athlete_name="Sample Athlete", + athlete_profile={"experience": "intermediate", "availability": ["Mon", "Tue", "Thu", "Sat"]}, + competitions=[{"id": "goal-1", "name": "Synthetic autumn event", "priority": "A"}], + plan_start_date="2026-08-03", + run_overrides={"temporary_constraints": "No training on Wednesdays"}, + ) + + +def _provider() -> HeadCoachCheckpointerProvider: + return HeadCoachCheckpointerProvider( + database_url="postgresql://unused", + injected_checkpointer=InMemorySaver(), + ) + + +def test_initial_context_preserves_declared_inputs_without_empty_provider_projections(): + context = _context() + + assert context["athlete_profile"]["availability"] == ["Mon", "Tue", "Thu", "Sat"] + assert context["competitions"][0]["id"] == "goal-1" + assert context["evidence_policy"]["provider_evidence_required"] is False + assert "metrics" not in context + assert "physiology" not in context + assert "activities" not in context + + +@pytest.mark.asyncio +async def test_model_planner_returns_invalid_artifact_to_model_for_one_bounded_repair(monkeypatch): + season, execution = _artifacts() + parsed_strategy = StrategyPlanningDecisionEnvelope(PlanningStrategy(season_strategy=season)) + invalid_strategy = {"kind": "artifacts", "bad": True} + strategy_invoke = AsyncMock( + side_effect=[ + {"raw": None, "parsed": invalid_strategy, "parsing_error": None}, + {"raw": None, "parsed": parsed_strategy.model_dump(mode="json"), "parsing_error": None}, + ] + ) + invalid_execution = execution.model_dump(mode="json") + for week in invalid_execution["weeks"]: + week["is_completed"] = False + execution_invoke = AsyncMock( + side_effect=[ + {"raw": None, "parsed": invalid_execution, "parsing_error": None}, + {"raw": None, "parsed": execution.model_dump(mode="json"), "parsing_error": None}, + ] + ) + + def with_structured_output(schema, **kwargs): + assert kwargs == {"method": "function_calling", "include_raw": True} + schema_name = schema["function"]["name"] + if schema_name == "StrategyPlanningDecisionEnvelope": + return SimpleNamespace(ainvoke=strategy_invoke) + if schema_name == "ExecutionPlanArtifactV3": + return SimpleNamespace(ainvoke=execution_invoke) + pytest.fail("unexpected structured-output schema") + + base_model = SimpleNamespace(with_structured_output=with_structured_output) + monkeypatch.setattr( + "services.ai.head_coach.initial_planning.ModelSelector.get_llm", lambda *_args, **_kwargs: base_model + ) + + planner = build_model_planner() + strategy_result = await planner( + { + "system_prompt": "system", + "coach_brief": {"local_context": _context()}, + "planning_stage": "strategy", + } + ) + execution_result = await planner( + { + "system_prompt": "system", + "coach_brief": {"local_context": _context()}, + "planning_stage": "execution", + "season_strategy": season.model_dump(mode="json"), + } + ) + + assert strategy_result["kind"] == "strategy" + assert execution_result["plan_id"] == execution.plan_id + assert strategy_invoke.await_count == 2 + assert execution_invoke.await_count == 2 + strategy_repair_prompt = strategy_invoke.await_args_list[1].args[0][1].content + execution_repair_prompt = execution_invoke.await_args_list[1].args[0][1].content + assert '"bad": true' in strategy_repair_prompt + assert "is_completed" in execution_repair_prompt + assert "weeks.0.is_completed" in execution_repair_prompt + assert "extra_forbidden" in execution_repair_prompt + + +@pytest.mark.asyncio +async def test_provider_free_initial_planning_commits_one_coherent_v3_pair(): + season, execution = _artifacts() + planner = AsyncMock( + side_effect=[ + {"kind": "strategy", "season_strategy": season.model_dump(mode="json")}, + execution.model_dump(mode="json"), + ] + ) + commit = AsyncMock() + phases: list[LifecyclePhase] = [] + + async def capture_status(phase: LifecyclePhase): + phases.append(phase) + + result = await run_initial_planning( + provider=_provider(), + owner_id=OWNER_ID, + job_id=JOB_ID, + context_pack=_context(), + planner=planner, + commit=commit, + status=capture_status, + ) + + assert result["committed"] is True + commit.assert_awaited_once_with(season, execution) + assert LifecyclePhase.UNDERSTANDING_CONTEXT in phases + assert LifecyclePhase.SAVING_PLAN in phases + assert planner.await_args is not None + request = planner.await_args.args[0] + assert request["coach_brief"]["local_context"] == _context() + assert request["planning_stage"] == "execution" + + +@pytest.mark.asyncio +async def test_clarification_resumes_same_checkpoint_and_does_not_repeat_initial_call(): + season, execution = _artifacts() + planner = AsyncMock( + side_effect=[ + { + "kind": "clarification", + "question": "Which four days are reliably available?", + "reason_markdown": "Availability materially changes the weekly structure.", + "requested_field": "availability", + }, + { + "kind": "strategy", + "season_strategy": season.model_dump(mode="json"), + }, + execution.model_dump(mode="json"), + ] + ) + commit = AsyncMock() + provider = _provider() + + paused = await run_initial_planning( + provider=provider, + owner_id=OWNER_ID, + job_id=JOB_ID, + context_pack=_context(), + planner=planner, + commit=commit, + ) + assert paused["__interrupt__"][0].value["requested_field"] == "availability" + commit.assert_not_awaited() + + resumed = await run_initial_planning( + provider=provider, + owner_id=OWNER_ID, + job_id=JOB_ID, + context_pack=_context(), + planner=planner, + commit=commit, + resume=Command(resume="Monday, Tuesday, Thursday, Saturday"), + ) + + assert resumed["committed"] is True + assert planner.await_count == 3 + assert planner.await_args_list[1].args[0]["clarification_answer"] == "Monday, Tuesday, Thursday, Saturday" + commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_execution_retry_resumes_without_repeating_durable_season_strategy(): + season, execution = _artifacts() + strategy_calls = 0 + execution_calls = 0 + execution_briefs: list[dict] = [] + + async def planner(request: dict) -> dict: + nonlocal strategy_calls, execution_calls + if request["planning_stage"] == "strategy": + strategy_calls += 1 + return {"kind": "strategy", "season_strategy": season.model_dump(mode="json")} + execution_calls += 1 + execution_briefs.append(request["coach_brief"]) + if execution_calls == 1: + raise RuntimeError("synthetic execution failure") + return execution.model_dump(mode="json") + + provider = _provider() + commit = AsyncMock() + with pytest.raises(RuntimeError, match="synthetic execution failure"): + await run_initial_planning( + provider=provider, + owner_id=OWNER_ID, + job_id=JOB_ID, + context_pack=_context(), + planner=planner, + commit=commit, + ) + + result = await run_initial_planning( + provider=provider, + owner_id=OWNER_ID, + job_id=JOB_ID, + context_pack={**_context(), "run_overrides": {"changed_after_failure": True}}, + planner=planner, + commit=commit, + ) + + assert result["committed"] is True + assert strategy_calls == 1 + assert execution_calls == 2 + assert execution_briefs[1] == execution_briefs[0] + assert execution_briefs[1]["local_context"]["run_overrides"] == { + "temporary_constraints": "No training on Wednesdays" + } + commit.assert_awaited_once_with(season, execution) diff --git a/tests/test_head_coach_interrupts.py b/tests/test_head_coach_interrupts.py new file mode 100644 index 0000000..610b704 --- /dev/null +++ b/tests/test_head_coach_interrupts.py @@ -0,0 +1,70 @@ +from typing import Any +from uuid import UUID + +import pytest +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command, interrupt + +from services.ai.head_coach.checkpointing import ( + CheckpointScope, + build_checkpoint_config, + build_checkpoint_identity, +) +from services.ai.head_coach.graph import HeadCoachGraphNodes, HeadCoachGraphState, build_head_coach_graph + +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") +RUN_ID = UUID("00000000-0000-0000-0000-000000000002") + + +async def _passthrough(_state: HeadCoachGraphState) -> dict[str, Any]: + return {} + + +@pytest.mark.asyncio +async def test_clarification_interrupt_survives_graph_recreation_and_resumes_once(): + commits: list[str] = [] + + async def ask_for_context(_state: HeadCoachGraphState) -> dict[str, Any]: + answer = interrupt( + { + "kind": "clarification", + "question": "Which four days are available?", + } + ) + return {"clarification_answer": answer} + + async def commit(state: HeadCoachGraphState) -> dict[str, Any]: + commits.append(str(state["clarification_answer"])) + return {"committed": True} + + checkpointer = InMemorySaver() + nodes = HeadCoachGraphNodes( + load_context=_passthrough, + design_strategy=_passthrough, + review_strategy=ask_for_context, + build_execution=_passthrough, + review_result=_passthrough, + commit_result=commit, + ) + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + config = build_checkpoint_config(identity) + + first_graph = build_head_coach_graph(nodes=nodes, checkpointer=checkpointer) + paused = await first_graph.ainvoke( + {"owner_id": str(OWNER_ID), "run_id": str(RUN_ID)}, + config=config, + ) + assert paused["__interrupt__"][0].value["kind"] == "clarification" + + recreated_graph = build_head_coach_graph(nodes=nodes, checkpointer=checkpointer) + resumed = await recreated_graph.ainvoke( + Command(resume="Monday, Tuesday, Thursday, Saturday"), + config=config, + ) + + assert resumed["committed"] is True + assert commits == ["Monday, Tuesday, Thursday, Saturday"] diff --git a/tests/test_head_coach_postgres_integration.py b/tests/test_head_coach_postgres_integration.py new file mode 100644 index 0000000..b7554e5 --- /dev/null +++ b/tests/test_head_coach_postgres_integration.py @@ -0,0 +1,300 @@ +import asyncio +import os +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from langgraph.types import Command, interrupt +from psycopg import AsyncConnection +from sqlalchemy import delete, func, select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from api.models.active_season_plan import ActiveSeasonPlan +from api.models.active_weekly_plan import ActiveWeeklyPlan +from api.models.ai_run_cost import AiRunCost +from api.models.coach_event import CoachEvent +from api.models.coach_thread import CoachThread +from api.models.job import AnalysisJob, JobStatus +from api.models.local_usage import LocalUsageEvent +from api.models.user import User +from api.services.local_usage import ensure_plan_generation_available +from api.services.plan_generation_lock import lock_owner_plan_generation +from services.ai.head_coach.checkpointing import ( + CheckpointScope, + HeadCoachCheckpointerProvider, + RunAlreadyClaimedError, + build_checkpoint_config, + build_checkpoint_identity, +) +from services.ai.head_coach.graph import HeadCoachGraphNodes, HeadCoachGraphState, build_head_coach_graph +from tests.test_head_coach_initial_planning import _artifacts + + +async def _passthrough(_state: HeadCoachGraphState) -> dict[str, Any]: + return {} + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_postgres_checkpoint_resumes_after_pool_and_graph_recreation(): + database_url = os.getenv("HEAD_COACH_TEST_DATABASE_URL") + if not database_url: + pytest.skip("HEAD_COACH_TEST_DATABASE_URL is not configured") + + owner_id = uuid4() + run_id = uuid4() + calls: list[str] = [] + identity = build_checkpoint_identity( + owner_id=owner_id, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=run_id, + ) + config = build_checkpoint_config(identity) + + async def load_context(_state: HeadCoachGraphState) -> dict[str, Any]: + calls.append("load") + return {"context_loaded": True} + + async def review_strategy(_state: HeadCoachGraphState) -> dict[str, Any]: + calls.append("strategy_review") + answer = interrupt({"kind": "clarification", "question": "Which days are available?"}) + return {"clarification_answer": answer} + + async def review_result(_state: HeadCoachGraphState) -> dict[str, Any]: + calls.append("review") + return {"reviewed": True} + + async def commit_result(_state: HeadCoachGraphState) -> dict[str, Any]: + calls.append("commit") + return {"committed": True} + + nodes = HeadCoachGraphNodes( + load_context=load_context, + design_strategy=_passthrough, + review_strategy=review_strategy, + build_execution=_passthrough, + review_result=review_result, + commit_result=commit_result, + ) + first_provider = HeadCoachCheckpointerProvider(database_url=database_url) + second_provider = HeadCoachCheckpointerProvider(database_url=database_url) + try: + first_graph = build_head_coach_graph(nodes=nodes, checkpointer=await first_provider.get()) + paused = await first_graph.ainvoke( + {"owner_id": str(owner_id), "run_id": str(run_id)}, + config=config, + ) + assert paused["__interrupt__"][0].value["kind"] == "clarification" + await first_provider.close() + + recreated_graph = build_head_coach_graph(nodes=nodes, checkpointer=await second_provider.get()) + resumed = await recreated_graph.ainvoke(Command(resume="Monday, Wednesday, Saturday"), config=config) + + assert resumed["committed"] is True + assert calls == ["load", "strategy_review", "strategy_review", "review", "commit"] + finally: + await first_provider.close() + await second_provider.close() + async with await AsyncConnection.connect(database_url, autocommit=True) as connection: + for table_name in ("checkpoint_writes", "checkpoint_blobs", "checkpoints"): + await connection.execute( + f"DELETE FROM {table_name} WHERE thread_id = %s", + (identity.thread_id,), + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_postgres_claim_blocks_overlapping_workers_and_releases_on_exit(): + database_url = os.getenv("HEAD_COACH_TEST_DATABASE_URL") + if not database_url: + pytest.skip("HEAD_COACH_TEST_DATABASE_URL is not configured") + + run_identity = f"integration:{uuid4()}" + first_provider = HeadCoachCheckpointerProvider(database_url=database_url) + second_provider = HeadCoachCheckpointerProvider(database_url=database_url) + try: + async with first_provider.claim(run_identity): + with pytest.raises(RunAlreadyClaimedError): + async with second_provider.claim(run_identity): + pass + + async with second_provider.claim(run_identity): + pass + finally: + await first_provider.close() + await second_provider.close() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_plan_generation_start_lock_serializes_owner_admission(): + database_url = os.getenv("HEAD_COACH_TEST_DATABASE_URL") + if not database_url: + pytest.skip("HEAD_COACH_TEST_DATABASE_URL is not configured") + async_database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(async_database_url) + sessions = async_sessionmaker(engine, expire_on_commit=False) + owner_id = uuid4() + owner_key = f"head-coach-admission-{owner_id}" + second_start_entered = asyncio.Event() + + async def attempt_second_start() -> None: + async with sessions() as second_db: + second_start_entered.set() + await lock_owner_plan_generation(second_db, user_id=owner_id) + await ensure_plan_generation_available(second_db, user_id=owner_id) + + try: + async with sessions() as setup_db: + setup_db.add(User(id=owner_id, local_owner_key=owner_key, email=f"{owner_key}@paced.local")) + await setup_db.commit() + + async with sessions() as first_db: + await lock_owner_plan_generation(first_db, user_id=owner_id) + first_db.add( + AnalysisJob( + user_id=owner_id, + status=JobStatus.PENDING.value, + config={"_workflow_version": "head_coach_v1"}, + ) + ) + await first_db.flush() + + second_start = asyncio.create_task(attempt_second_start()) + await second_start_entered.wait() + with pytest.raises(TimeoutError): + await asyncio.wait_for(asyncio.shield(second_start), timeout=0.05) + await first_db.commit() + + with pytest.raises(HTTPException) as exc: + await second_start + assert exc.value.status_code == 409 + finally: + async with sessions() as cleanup_db: + await cleanup_db.execute(delete(AnalysisJob).where(AnalysisJob.user_id == owner_id)) + await cleanup_db.execute(delete(User).where(User.id == owner_id)) + await cleanup_db.commit() + await engine.dispose() + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_worker_head_coach_commit_is_atomic_and_idempotent(monkeypatch): + database_url = os.getenv("HEAD_COACH_TEST_DATABASE_URL") + if not database_url: + pytest.skip("HEAD_COACH_TEST_DATABASE_URL is not configured") + async_database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(async_database_url) + sessions = async_sessionmaker(engine, expire_on_commit=False) + owner_id = uuid4() + job_id = uuid4() + owner_key = f"head-coach-test-{owner_id}" + season, execution = _artifacts() + + async def planner(request: dict[str, Any]) -> dict[str, Any]: + if request["planning_stage"] == "strategy": + return {"kind": "strategy", "season_strategy": season.model_dump(mode="json")} + return execution.model_dump(mode="json") + + monkeypatch.setattr("worker.tasks.build_model_planner", lambda: planner) + from worker.tasks import _run_head_coach_workflow_for_job + + identity = build_checkpoint_identity( + owner_id=owner_id, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=job_id, + ) + try: + async with sessions() as db: + db.add(User(id=owner_id, local_owner_key=owner_key, email=f"{owner_key}@paced.local")) + await db.flush() + db.add( + AnalysisJob( + id=job_id, + user_id=owner_id, + status=JobStatus.RUNNING.value, + config={"_workflow_version": "head_coach_v1"}, + ) + ) + await db.commit() + + async def run_once(): + return await _run_head_coach_workflow_for_job( + job_uuid=job_id, + user_id=owner_id, + config={ + "athlete_name": "Sample Athlete", + "athlete_profile": {"availability": ["Monday", "Thursday", "Saturday"]}, + "competitions": [], + "plan_start_date": "2026-08-03", + }, + prior_season_plan=None, + prior_weekly_plan=None, + coach_memory={"memory_summary": "Prefers consistency over hero workouts."}, + is_initial_draft_run=False, + ) + + first = await run_once() + second = await run_once() + + assert first["committed"] is True + assert second["committed"] is True + async with sessions() as db: + job = await db.get(AnalysisJob, job_id) + active_season = ( + await db.execute(select(ActiveSeasonPlan).where(ActiveSeasonPlan.user_id == owner_id)) + ).scalar_one() + active_weekly = ( + await db.execute(select(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == owner_id)) + ).scalar_one() + event_count = ( + await db.execute( + select(func.count(CoachEvent.id)) + .join(CoachThread, CoachThread.id == CoachEvent.thread_id) + .where(CoachThread.user_id == owner_id, CoachEvent.event_type == "plan_decision_recorded") + ) + ).scalar_one() + assert job is not None and job.status == JobStatus.COMPLETED.value and job.result is not None + assert job.result["season_plan_blocks"]["schema_version"] == 3 + assert active_season.source_job_id == job_id + assert active_weekly.source_job_id == job_id + assert event_count == 1 + assert ( + await db.execute( + select(func.count(LocalUsageEvent.id)).where( + LocalUsageEvent.user_id == owner_id, + LocalUsageEvent.source_id == str(job_id), + ) + ) + ).scalar_one() == 1 + assert ( + await db.execute( + select(func.count(AiRunCost.id)).where( + AiRunCost.user_id == owner_id, + AiRunCost.source_id == str(job_id), + ) + ) + ).scalar_one() == 1 + finally: + async with sessions() as db: + await db.execute( + delete(CoachEvent).where( + CoachEvent.thread_id.in_(select(CoachThread.id).where(CoachThread.user_id == owner_id)) + ) + ) + await db.execute(delete(AiRunCost).where(AiRunCost.user_id == owner_id)) + await db.execute(delete(LocalUsageEvent).where(LocalUsageEvent.user_id == owner_id)) + await db.execute(delete(ActiveSeasonPlan).where(ActiveSeasonPlan.user_id == owner_id)) + await db.execute(delete(ActiveWeeklyPlan).where(ActiveWeeklyPlan.user_id == owner_id)) + await db.execute(delete(CoachThread).where(CoachThread.user_id == owner_id)) + for table_name in ("checkpoint_writes", "checkpoint_blobs", "checkpoints"): + await db.execute( + text(f"DELETE FROM {table_name} WHERE thread_id = :thread_id"), + {"thread_id": identity.thread_id}, + ) + await db.execute(delete(AnalysisJob).where(AnalysisJob.id == job_id)) + await db.execute(delete(User).where(User.id == owner_id)) + await db.commit() + await engine.dispose() diff --git a/tests/test_head_coach_retention_service.py b/tests/test_head_coach_retention_service.py new file mode 100644 index 0000000..03476c7 --- /dev/null +++ b/tests/test_head_coach_retention_service.py @@ -0,0 +1,36 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + + +@pytest.mark.asyncio +async def test_retention_deletes_terminal_analysis_and_coach_execution_threads(monkeypatch): + from api.services import head_coach_checkpoint_retention as retention + + owner_id = uuid4() + analysis_id = uuid4() + coach_thread_id = uuid4() + coach_run_id = uuid4() + db = AsyncMock() + db.execute.side_effect = [ + SimpleNamespace(all=lambda: [(owner_id, analysis_id)]), + SimpleNamespace(all=lambda: [(owner_id, coach_thread_id, coach_run_id)]), + ] + delete_thread = AsyncMock(side_effect=[4, 3]) + monkeypatch.setattr(retention, "delete_checkpoint_thread", delete_thread) + + summary = await retention.cleanup_expired_head_coach_checkpoints( + db, + cutoff=datetime(2026, 7, 12, tzinfo=UTC), + ) + + assert summary.analysis_threads_deleted == 1 + assert summary.coach_executions_deleted == 1 + assert summary.checkpoint_rows_deleted == 7 + assert delete_thread.await_args_list[0].kwargs == {"thread_id": f"owner:{owner_id}:analysis:{analysis_id}"} + assert delete_thread.await_args_list[1].kwargs == { + "thread_id": f"owner:{owner_id}:coach:{coach_thread_id}:scope:coach_turn:v1:run:{coach_run_id}" + } diff --git a/tests/test_head_coach_runtime.py b/tests/test_head_coach_runtime.py new file mode 100644 index 0000000..9ad74f5 --- /dev/null +++ b/tests/test_head_coach_runtime.py @@ -0,0 +1,254 @@ +from collections.abc import Awaitable, Callable +from typing import Any +from uuid import UUID + +import pytest +from langchain.agents.structured_output import ToolStrategy +from langgraph.checkpoint.memory import InMemorySaver +from pydantic import BaseModel + +from services.ai.head_coach import agent as head_coach_agent +from services.ai.head_coach.checkpointing import ( + CheckpointScope, + HeadCoachCheckpointerProvider, + build_checkpoint_config, + build_checkpoint_identity, +) +from services.ai.head_coach.graph import ( + HeadCoachGraphNodes, + HeadCoachGraphState, + build_head_coach_graph, + run_head_coach_execution, +) +from services.ai.head_coach.middleware import build_head_coach_middleware +from services.ai.head_coach.run_profiles import RunProfileName, get_run_profile + +OWNER_ID = UUID("00000000-0000-0000-0000-000000000001") +RUN_ID = UUID("00000000-0000-0000-0000-000000000002") + + +class _FactoryOutput(BaseModel): + answer: str + + +def _node( + name: str, + calls: list[str], + *, + update: dict[str, Any] | None = None, +) -> Callable[[HeadCoachGraphState], Awaitable[dict[str, Any]]]: + async def run(_state: HeadCoachGraphState) -> dict[str, Any]: + calls.append(name) + return update or {} + + return run + + +@pytest.mark.asyncio +async def test_head_coach_graph_runs_stable_lifecycle_spine_with_checkpointing(): + calls: list[str] = [] + graph = build_head_coach_graph( + nodes=HeadCoachGraphNodes( + load_context=_node("load", calls, update={"context_loaded": True}), + design_strategy=_node("strategy", calls, update={"strategy": {"answer": "synthetic"}}), + review_strategy=_node("strategy_review", calls, update={"strategy_reviewed": True}), + build_execution=_node("execution", calls, update={"draft": {"answer": "synthetic"}}), + review_result=_node("review", calls, update={"reviewed": True}), + commit_result=_node("commit", calls, update={"committed": True}), + ), + checkpointer=InMemorySaver(), + ) + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + + result = await graph.ainvoke( + {"owner_id": str(OWNER_ID), "run_id": str(RUN_ID)}, + config=build_checkpoint_config(identity), + ) + + assert calls == ["load", "strategy", "strategy_review", "execution", "review", "commit"] + assert result["committed"] is True + + +@pytest.mark.asyncio +async def test_resume_does_not_repeat_completed_expensive_node(): + calls: list[str] = [] + review_attempts = 0 + + async def flaky_review(_state: HeadCoachGraphState) -> dict[str, Any]: + nonlocal review_attempts + review_attempts += 1 + calls.append("review") + if review_attempts == 1: + raise RuntimeError("synthetic review failure") + return {"reviewed": True} + + checkpointer = InMemorySaver() + graph = build_head_coach_graph( + nodes=HeadCoachGraphNodes( + load_context=_node("load", calls), + design_strategy=_node("strategy", calls, update={"strategy": {"answer": "expensive"}}), + review_strategy=_node("strategy_review", calls), + build_execution=_node("execution", calls, update={"draft": {"answer": "expensive"}}), + review_result=flaky_review, + commit_result=_node("commit", calls, update={"committed": True}), + ), + checkpointer=checkpointer, + ) + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + config = build_checkpoint_config(identity) + + with pytest.raises(RuntimeError, match="synthetic review failure"): + await graph.ainvoke({"owner_id": str(OWNER_ID), "run_id": str(RUN_ID)}, config=config) + + result = await graph.ainvoke(None, config=config) + + assert calls == ["load", "strategy", "strategy_review", "execution", "review", "review", "commit"] + assert result["committed"] is True + + +def test_middleware_uses_limits_and_retries_without_context_summarization(): + profile = get_run_profile(RunProfileName.INITIAL_PLANNING) + + middleware = build_head_coach_middleware(profile) + middleware_names = {type(item).__name__ for item in middleware} + + assert middleware_names == { + "ModelCallLimitMiddleware", + "ModelRetryMiddleware", + "ToolCallLimitMiddleware", + "ToolRetryMiddleware", + } + assert "SummarizationMiddleware" not in middleware_names + + +def test_shared_agent_factory_applies_profile_model_prompt_middleware_and_tool_strategy(monkeypatch): + fake_model = object() + fake_agent = object() + model_calls: list[tuple[object, dict[str, object]]] = [] + create_calls: list[dict[str, Any]] = [] + + def fake_get_llm(role, **kwargs): + model_calls.append((role, kwargs)) + return fake_model + + def fake_create_agent(**kwargs): + create_calls.append(kwargs) + return fake_agent + + monkeypatch.setattr(head_coach_agent.ModelSelector, "get_llm", fake_get_llm) + monkeypatch.setattr(head_coach_agent, "create_agent", fake_create_agent) + + result = head_coach_agent.build_head_coach_agent( + profile_name=RunProfileName.COACH_TURN, + response_schema=_FactoryOutput, + tools=[], + task_instructions="Answer the current coaching question.", + name="test_head_coach", + ) + + assert result is fake_agent + assert model_calls[0][1] == { + "reasoning_effort": "medium", + "enable_native_web_search": False, + } + create_call = create_calls[0] + assert create_call["model"] is fake_model + assert "persistent Head Coach" in create_call["system_prompt"] + assert "Answer the current coaching question." in create_call["system_prompt"] + assert isinstance(create_call["response_format"], ToolStrategy) + assert create_call["response_format"].schema is _FactoryOutput + assert {type(item).__name__ for item in create_call["middleware"]} == { + "ModelCallLimitMiddleware", + "ModelRetryMiddleware", + "ToolCallLimitMiddleware", + "ToolRetryMiddleware", + } + + +@pytest.mark.asyncio +async def test_duplicate_delivery_returns_terminal_checkpoint_without_duplicate_commit(): + calls: list[str] = [] + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + provider = HeadCoachCheckpointerProvider( + database_url="postgresql://unused", + injected_checkpointer=InMemorySaver(), + ) + nodes = HeadCoachGraphNodes( + load_context=_node("load", calls), + design_strategy=_node("strategy", calls), + review_strategy=_node("strategy_review", calls), + build_execution=_node("execution", calls), + review_result=_node("review", calls), + commit_result=_node("commit", calls, update={"committed": True}), + ) + initial_state: HeadCoachGraphState = {"owner_id": str(OWNER_ID), "run_id": str(RUN_ID)} + + first = await run_head_coach_execution( + provider=provider, + identity=identity, + nodes=nodes, + graph_input=initial_state, + ) + duplicate = await run_head_coach_execution( + provider=provider, + identity=identity, + nodes=nodes, + graph_input=initial_state, + ) + + assert first["committed"] is True + assert duplicate["committed"] is True + assert calls == ["load", "strategy", "strategy_review", "execution", "review", "commit"] + + +@pytest.mark.asyncio +async def test_cancellation_guard_wins_before_domain_commit(): + calls: list[str] = [] + guard_checks = 0 + + async def ensure_active() -> None: + nonlocal guard_checks + guard_checks += 1 + if guard_checks > 1: + raise RuntimeError("cancelled") + + identity = build_checkpoint_identity( + owner_id=OWNER_ID, + scope=CheckpointScope.INITIAL_PLANNING, + resource_id=RUN_ID, + ) + provider = HeadCoachCheckpointerProvider( + database_url="postgresql://unused", + injected_checkpointer=InMemorySaver(), + ) + nodes = HeadCoachGraphNodes( + load_context=_node("load", calls), + design_strategy=_node("strategy", calls), + review_strategy=_node("strategy_review", calls), + build_execution=_node("execution", calls), + review_result=_node("review", calls), + commit_result=_node("commit", calls, update={"committed": True}), + ) + + with pytest.raises(RuntimeError, match="cancelled"): + await run_head_coach_execution( + provider=provider, + identity=identity, + nodes=nodes, + graph_input={"owner_id": str(OWNER_ID), "run_id": str(RUN_ID)}, + ensure_active=ensure_active, + ) + + assert calls == ["load", "strategy", "strategy_review", "execution", "review"] diff --git a/tests/test_head_coach_tool_policy.py b/tests/test_head_coach_tool_policy.py new file mode 100644 index 0000000..9f18c65 --- /dev/null +++ b/tests/test_head_coach_tool_policy.py @@ -0,0 +1,73 @@ +from typing import Any, cast + +import pytest + +from api.services.ongoing_tools import OngoingToolRegistry +from services.ai.head_coach.run_profiles import RunProfileName, get_run_profile +from services.ai.head_coach.tool_policy import ( + ToolAccess, + ToolCapability, + select_tool_names, + tool_specs_for_names, +) + +LOCAL_CAPABILITIES = frozenset( + { + ToolCapability.ATHLETE_PROFILE, + ToolCapability.COMPETITIONS, + ToolCapability.ACTIVE_PLANS, + } +) + + +def test_provider_free_initial_planning_exposes_only_local_read_tools(): + profile = get_run_profile(RunProfileName.INITIAL_PLANNING) + selected = select_tool_names( + profile, + available_capabilities=LOCAL_CAPABILITIES, + registered_tool_names=OngoingToolRegistry.registered_tool_names(), + ) + + assert selected == { + "get_athlete_profile", + "get_current_season_plan", + "get_current_weekly_plan", + "get_upcoming_competitions", + } + assert all(spec.access is ToolAccess.READ for spec in tool_specs_for_names(selected)) + + +def test_coach_turn_exposes_only_local_sources(): + profile = get_run_profile(RunProfileName.COACH_TURN) + selected = select_tool_names( + profile, + available_capabilities=LOCAL_CAPABILITIES, + registered_tool_names=OngoingToolRegistry.registered_tool_names(), + ) + + assert selected == OngoingToolRegistry.registered_tool_names() + + +def test_unclassified_registry_tool_fails_closed(): + profile = get_run_profile(RunProfileName.COACH_TURN) + + with pytest.raises(ValueError, match="Unclassified Head Coach tools"): + select_tool_names( + profile, + available_capabilities=LOCAL_CAPABILITIES, + registered_tool_names={"surprise_write_tool"}, + ) + + +def test_registry_filters_constructed_tools_by_policy(): + registry = OngoingToolRegistry( + db=cast("Any", object()), + user_id="owner-1", + ) + + tools = registry.create_langchain_tools(allowed_tool_names={"get_athlete_profile", "get_current_weekly_plan"}) + + assert {cast("Any", tool).name for tool in tools} == { + "get_athlete_profile", + "get_current_weekly_plan", + } diff --git a/tests/test_head_coach_ui_composer.py b/tests/test_head_coach_ui_composer.py new file mode 100644 index 0000000..dd33459 --- /dev/null +++ b/tests/test_head_coach_ui_composer.py @@ -0,0 +1,130 @@ +from datetime import UTC, date, datetime +from unittest.mock import AsyncMock + +import pytest +from pydantic import ValidationError + +from services.ai.head_coach.artifact_rendering import render_artifact_payload +from services.ai.head_coach.artifacts import ( + ArtifactSection, + DecisionLedgerEntry, + NarrativeBlock, + SeasonPhase, + SeasonStrategyArtifactV3, +) +from services.ai.head_coach.ui_composer import ( + ContainerComposition, + PresentationCompositionV3, + UiCompositionError, + artifact_semantic_hash, + compose_with_repair, + validate_composition, +) + + +def _artifact() -> SeasonStrategyArtifactV3: + return SeasonStrategyArtifactV3( + plan_id="season-synthetic", + version=1, + athlete_name="Sample Athlete", + created_at=datetime(2026, 8, 3, 9, tzinfo=UTC), + title="Autumn foundation", + summary_markdown="Build repeatability before specificity.", + start_date=date(2026, 8, 3), + end_date=date(2026, 11, 1), + phases=[ + SeasonPhase( + phase_id="phase-foundation", + title="Foundation", + start_date=date(2026, 8, 3), + end_date=date(2026, 8, 30), + objective_markdown="Establish repeatable training frequency.", + ) + ], + sections=[ + ArtifactSection( + section_id="principles", + title="Principles", + blocks=[NarrativeBlock(block_id="principle-1", markdown="Consistency compounds.")], + ), + ArtifactSection( + section_id="guardrails", + title="Guardrails", + blocks=[NarrativeBlock(block_id="guardrail-1", markdown="Never make up missed work.")], + ), + ], + decision_ledger_entry=DecisionLedgerEntry( + decision_id="decision-1", + decided_at=datetime(2026, 8, 3, 9, tzinfo=UTC), + title="Foundation first", + rationale_markdown="Repeatability is the limiting factor.", + changes_markdown="Created the first season strategy.", + ), + ) + + +def _valid_composition(artifact: SeasonStrategyArtifactV3) -> dict: + return PresentationCompositionV3( + artifact_id=artifact.plan_id, + semantic_hash=artifact_semantic_hash(artifact), + section_order=["guardrails", "principles"], + containers=[ + ContainerComposition(container_id="section:principles", block_order=["principle-1"]), + ContainerComposition(container_id="section:guardrails", block_order=["guardrail-1"]), + ], + featured_block_ids=["guardrail-1"], + ).model_dump(mode="json") + + +def test_ui_composer_can_reorder_presentation_but_not_change_semantics(): + artifact = _artifact() + composition = PresentationCompositionV3.model_validate(_valid_composition(artifact)) + + validated = validate_composition(artifact, composition) + + assert validated.section_order == ["guardrails", "principles"] + assert validated.semantic_hash == artifact_semantic_hash(artifact) + assert artifact.sections[0].section_id == "principles" + + rendered = render_artifact_payload(artifact, validated) + assert [section["section_id"] for section in rendered["sections"]] == ["guardrails", "principles"] + assert rendered["presentation"]["featured_block_ids"] == ["guardrail-1"] + assert artifact_semantic_hash(artifact) == validated.semantic_hash + + +def test_ui_composition_rejects_non_v3_schema_version(): + payload = _valid_composition(_artifact()) + payload["schema_version"] = 2 + + with pytest.raises(ValidationError, match="Input should be 3"): + PresentationCompositionV3.model_validate(payload) + + +@pytest.mark.asyncio +async def test_invalid_composition_is_returned_to_model_for_one_bounded_repair(): + artifact = _artifact() + invalid = _valid_composition(artifact) + invalid["semantic_hash"] = "changed-coaching-content" + composer = AsyncMock(side_effect=[invalid, _valid_composition(artifact)]) + + result = await compose_with_repair(artifact, composer, max_attempts=2) + + assert result.semantic_hash == artifact_semantic_hash(artifact) + assert composer.await_count == 2 + repair_request = composer.await_args_list[1].args[0] + assert repair_request["rejected_output"] == invalid + assert repair_request["validation_errors"] + assert "fallback" not in repair_request + + +@pytest.mark.asyncio +async def test_exhausted_ui_composer_repair_fails_visibly_without_fallback(): + artifact = _artifact() + invalid = _valid_composition(artifact) + invalid["section_order"] = ["principles"] + composer = AsyncMock(return_value=invalid) + + with pytest.raises(UiCompositionError, match="repair budget exhausted"): + await compose_with_repair(artifact, composer, max_attempts=2) + + assert composer.await_count == 2 diff --git a/tests/test_integration_status_service.py b/tests/test_integration_status_service.py deleted file mode 100644 index f96478e..0000000 --- a/tests/test_integration_status_service.py +++ /dev/null @@ -1,282 +0,0 @@ -from datetime import UTC, datetime, timedelta -from types import SimpleNamespace -from unittest.mock import AsyncMock -from uuid import uuid4 - -import pytest -from sqlalchemy.exc import ProgrammingError - -from api.models.credentials import StravaCredentials, WhoopCredentials -from api.models.integration_connection import IntegrationConnection -from api.models.oauth_session import OAuthSession -from api.services.integration_connections import get_connection_history_map -from api.services.integration_status import ( - build_integrations_status, - has_operational_training_provider, - training_provider_requirement_message, -) - - -def _settings(**overrides): - values = { - "strava_oauth_enabled": True, - "strava_oauth_client_id": "client-id", - "strava_oauth_client_secret": "client-secret", - "strava_oauth_redirect_uri": "http://localhost:3000/app/api/oauth/strava/callback", - "whoop_oauth_enabled": True, - "whoop_oauth_client_id": "client-id", - "whoop_oauth_client_secret": "client-secret", - "whoop_oauth_redirect_uri": "http://localhost:3000/app/api/oauth/whoop/callback", - } - values.update(overrides) - return SimpleNamespace(**values) - - -def _history(provider: str, **overrides) -> IntegrationConnection: - values = { - "user_id": uuid4(), - "provider": provider, - "first_connected_at": datetime(2026, 3, 1, tzinfo=UTC), - "last_connected_at": datetime(2026, 3, 6, tzinfo=UTC), - "last_disconnected_at": datetime(2026, 3, 7, tzinfo=UTC), - "last_disconnect_reason": "user_initiated", - } - values.update(overrides) - return IntegrationConnection(**values) - - -@pytest.mark.unit -def test_integrations_status_marks_absent_providers_disconnected(): - status = build_integrations_status( - settings=_settings(), - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.state == "disconnected" - assert status.strava.connection_state == "disconnected" - assert status.whoop.state == "disconnected" - assert status.whoop.connection_state == "disconnected" - assert status.strava.ever_connected is False - assert status.whoop.ever_connected is False - assert has_operational_training_provider(status) is False - assert training_provider_requirement_message(status) == "No training data source connected. Connect a supported training source first." - - -@pytest.mark.unit -def test_integrations_status_marks_strava_attention_when_scope_is_insufficient(): - strava = StravaCredentials( - user_id=uuid4(), - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 8, tzinfo=UTC), - scope="activity:read", - strava_athlete_id=12345, - ) - - status = build_integrations_status( - settings=_settings(), - strava=strava, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.linked is True - assert status.strava.operational is False - assert status.strava.connected is False - assert status.strava.state == "attention_needed" - assert status.strava.connection_state == "partial_permissions" - assert status.strava.athlete_id == 12345 - assert "accepted scopes do not allow complete activity history" in (status.strava.attention_message or "") - - -@pytest.mark.unit -def test_integrations_status_treats_refreshable_strava_as_operational(): - strava = StravaCredentials( - user_id=uuid4(), - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 6, tzinfo=UTC), - scope="activity:read_all", - strava_athlete_id=12345, - ) - - status = build_integrations_status( - settings=_settings(), - strava=strava, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.linked is True - assert status.strava.operational is True - assert status.strava.connected is True - assert status.strava.state == "connected" - assert status.strava.connection_state == "connected_usable" - assert has_operational_training_provider(status) is True - - -@pytest.mark.unit -def test_integrations_status_refreshable_tokens_do_not_require_redirect_uri(): - whoop = WhoopCredentials( - user_id=uuid4(), - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 6, tzinfo=UTC), - scope="offline read:recovery", - whoop_user_id=42, - ) - - status = build_integrations_status( - settings=_settings(whoop_oauth_redirect_uri=""), - whoop=whoop, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.whoop.operational is True - assert status.whoop.connection_state == "connected_usable" - - -@pytest.mark.unit -def test_integrations_status_marks_expired_whoop_without_refresh_as_attention_needed(): - whoop = WhoopCredentials( - user_id=uuid4(), - encrypted_access_token=b"access-token", - encrypted_refresh_token=None, - expires_at=datetime(2026, 3, 6, tzinfo=UTC), - scope="offline read:recovery", - whoop_user_id=42, - ) - - status = build_integrations_status( - settings=_settings(), - whoop=whoop, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.whoop.linked is True - assert status.whoop.operational is False - assert status.whoop.connected is False - assert status.whoop.state == "attention_needed" - assert status.whoop.connection_state == "token_expired" - assert "Reconnect WHOOP" in (status.whoop.attention_message or "") - - -@pytest.mark.unit -def test_integrations_status_marks_previously_connected_provider_as_disconnected_history(): - history = _history("strava", last_disconnect_reason="provider_deregistered") - - status = build_integrations_status( - settings=_settings(), - strava_history=history, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.linked is False - assert status.strava.connection_state == "disconnected" - assert status.strava.ever_connected is True - assert status.strava.last_disconnect_reason == "provider_deregistered" - assert status.strava.last_disconnected_at == "2026-03-07T00:00:00+00:00" - assert training_provider_requirement_message(status) == ( - "Strava was disconnected. Reconnect it in Settings before starting a run." - ) - - -@pytest.mark.unit -def test_integrations_status_marks_provider_unconfigured_when_enabled_without_redirect_uri(): - status = build_integrations_status( - settings=_settings(strava_oauth_redirect_uri=""), - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.linked is False - assert status.strava.state == "attention_needed" - assert status.strava.connection_state == "unconfigured" - assert status.strava.configured is False - assert status.strava.oauth_enabled is True - assert "redirect URI are missing" in (status.strava.attention_message or "") - - -@pytest.mark.unit -def test_integrations_status_marks_disabled_provider_without_blocking_manual_mode(): - status = build_integrations_status( - settings=_settings(strava_oauth_enabled=False), - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.state == "disconnected" - assert status.strava.connection_state == "disabled" - assert status.strava.oauth_enabled is False - assert has_operational_training_provider(status) is False - assert training_provider_requirement_message(status) == "No training data source connected. Connect a supported training source first." - - -@pytest.mark.unit -def test_integrations_status_marks_started_oauth_session(): - session = OAuthSession( - user_id=uuid4(), - provider="strava", - state="state-123", - code_verifier=None, - expires_at=datetime(2026, 3, 7, 0, 10, tzinfo=UTC), - used_at=None, - ) - - status = build_integrations_status( - settings=_settings(), - strava_oauth_session=session, - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - assert status.strava.linked is False - assert status.strava.state == "attention_needed" - assert status.strava.connection_state == "started" - assert "Complete the provider approval flow" in (status.strava.attention_message or "") - - -@pytest.mark.unit -def test_integrations_status_marks_callback_failed_only_until_disconnect_supersedes_session(): - user_id = uuid4() - used_at = datetime(2026, 3, 7, 0, 5, tzinfo=UTC) - session = OAuthSession( - user_id=user_id, - provider="whoop", - state="state-123", - code_verifier=None, - expires_at=used_at + timedelta(minutes=5), - used_at=used_at, - ) - - failed_status = build_integrations_status( - settings=_settings(), - whoop_oauth_session=session, - now=datetime(2026, 3, 7, 0, 6, tzinfo=UTC), - ) - assert failed_status.whoop.connection_state == "callback_failed" - - disconnected_status = build_integrations_status( - settings=_settings(), - whoop_oauth_session=session, - whoop_history=_history( - "whoop", - user_id=user_id, - last_disconnected_at=used_at + timedelta(minutes=1), - ), - now=datetime(2026, 3, 7, 0, 7, tzinfo=UTC), - ) - assert disconnected_status.whoop.connection_state == "disconnected" - - -class _FakeUndefinedTableError(Exception): - sqlstate = "42P01" - - def __str__(self) -> str: - return 'relation "integration_connections" does not exist' - - -@pytest.mark.asyncio -async def test_get_connection_history_map_returns_empty_when_history_table_missing(): - fake_db = SimpleNamespace( - execute=AsyncMock(side_effect=ProgrammingError("SELECT 1", {}, _FakeUndefinedTableError())) - ) - - history_map = await get_connection_history_map(fake_db, user_id=uuid4()) - - assert history_map == {} diff --git a/tests/test_langgraph_core_migration.py b/tests/test_langgraph_core_migration.py deleted file mode 100644 index 556a327..0000000 --- a/tests/test_langgraph_core_migration.py +++ /dev/null @@ -1,120 +0,0 @@ -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from services.ai.langgraph.state.training_analysis_state import create_initial_state - - -@pytest.fixture -def basic_test_state(): - return create_initial_state( - user_id="test_user", - athlete_name="Test Athlete", - training_data={ - "generated_at_utc": "2024-01-02T00:00:00+00:00", - "sources": {"strava": {"activities": [], "training_load_history": []}}, - }, - execution_id="test_123", - ) - - -def test_all_nodes_importable(): - from services.ai.langgraph.nodes.activity_expert_node import activity_expert_node - from services.ai.langgraph.nodes.activity_summarizer_node import activity_summarizer_node - from services.ai.langgraph.nodes.metrics_expert_node import metrics_expert_node - from services.ai.langgraph.nodes.metrics_summarizer_node import metrics_summarizer_node - from services.ai.langgraph.nodes.physiology_expert_node import physiology_expert_node - from services.ai.langgraph.nodes.physiology_summarizer_node import physiology_summarizer_node - from services.ai.langgraph.nodes.synthesis_node import synthesis_node - - assert callable(metrics_summarizer_node) - assert callable(metrics_expert_node) - assert callable(physiology_summarizer_node) - assert callable(physiology_expert_node) - assert callable(activity_summarizer_node) - assert callable(activity_expert_node) - assert callable(synthesis_node) - - -@patch("services.ai.langgraph.config.langsmith_config.LangSmithConfig.setup_langsmith") -def test_complete_workflow_creation(mock_langsmith): - from services.ai.langgraph.workflows.analysis_workflow import create_analysis_workflow - - workflow_app = create_analysis_workflow() - assert workflow_app is not None - mock_langsmith.assert_called_once() - - -def test_state_schema_completeness(): - state = create_initial_state( - user_id="test", - athlete_name="Test", - training_data={"generated_at_utc": "2024-01-02T00:00:00+00:00", "sources": {"strava": {}}}, - execution_id="test", - ) - - required_fields = [ - "user_id", - "athlete_name", - "training_data", - "execution_id", - "metrics_summary", - "physiology_summary", - "metrics_outputs", - "activity_summary", - "activity_outputs", - "physiology_outputs", - "synthesis_result", - "analysis_blocks", - "plots", - "costs", - "errors", - ] - - for field in required_fields: - assert field in state - - -@pytest.mark.asyncio -@patch("services.ai.model_config.ModelSelector.get_llm") -async def test_node_basic_functionality(mock_get_llm, basic_test_state): - from services.ai.langgraph.nodes.activity_summarizer_node import activity_summarizer_node - - mock_llm = AsyncMock() - mock_response = Mock() - mock_response.content = "Test response" - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_get_llm.return_value = mock_llm - - result = await activity_summarizer_node(basic_test_state) - - assert isinstance(result, dict) - assert "costs" in result or "errors" in result - - if "errors" not in result: - mock_llm.ainvoke.assert_called_once() - call_args = mock_llm.ainvoke.call_args[0][0] - - assert isinstance(call_args, list) - for message in call_args: - assert isinstance(message, dict) - assert "role" in message - assert "content" in message - - -def test_workflow_structure_stability(): - try: - with patch("services.ai.langgraph.config.langsmith_config.LangSmithConfig.setup_langsmith"): - from services.ai.langgraph.workflows.analysis_workflow import ( - create_analysis_workflow, - create_simple_sequential_workflow, - ) - - parallel_workflow = create_analysis_workflow() - sequential_workflow = create_simple_sequential_workflow() - - assert parallel_workflow is not None - assert sequential_workflow is not None - - except Exception as exception: - pytest.fail(f"Workflow creation should be stable: {exception}") diff --git a/tests/test_langgraph_foundation.py b/tests/test_langgraph_foundation.py deleted file mode 100644 index d384c5c..0000000 --- a/tests/test_langgraph_foundation.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -from unittest.mock import patch - -import pytest - -from services.ai.langgraph.config.langsmith_config import LangSmithConfig -from services.ai.langgraph.state.training_analysis_state import ( - TrainingAnalysisState, - create_initial_state, -) - - -class TestLangGraphFoundation: - - def test_state_creation(self): - state = create_initial_state( - user_id="test_user", athlete_name="Test Athlete", training_data={"test": "data"} - ) - - assert state["user_id"] == "test_user" - assert state["athlete_name"] == "Test Athlete" - assert state["training_data"] == {"test": "data"} - assert isinstance(state["plots"], list) - assert isinstance(state["costs"], list) - assert isinstance(state["errors"], list) - - def test_langgraph_import(self): - from langgraph.graph import StateGraph - - workflow = StateGraph(TrainingAnalysisState) - assert workflow is not None - - @patch.dict(os.environ, {"LANGSMITH_API_KEY": "test_key"}, clear=True) - def test_langsmith_config(self): - assert LangSmithConfig.setup_langsmith("test_project") is True - assert os.getenv("LANGSMITH_PROJECT") == "test_project" - assert os.getenv("LANGCHAIN_PROJECT") == "test_project" - assert os.getenv("LANGCHAIN_API_KEY") == "test_key" - assert os.getenv("LANGSMITH_TRACING_V2") == "true" - assert os.getenv("LANGCHAIN_TRACING_V2") == "true" - - def test_module_imports(self): - from services.ai.langgraph import TrainingAnalysisState - from services.ai.langgraph.config import LangSmithConfig - - assert TrainingAnalysisState is not None - assert LangSmithConfig is not None - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_langgraph_planning_workflow.py b/tests/test_langgraph_planning_workflow.py deleted file mode 100644 index c394605..0000000 --- a/tests/test_langgraph_planning_workflow.py +++ /dev/null @@ -1,195 +0,0 @@ -import pytest - -from services.ai.langgraph.state.training_analysis_state import ( - TrainingAnalysisState, - create_initial_state, -) -from services.ai.langgraph.workflows.planning_workflow import ( - create_integrated_analysis_and_planning_workflow, - create_planning_workflow, -) - - -class TestWorkflowStability: - - def test_planning_workflow_creation(self): - workflow = create_planning_workflow() - assert workflow is not None - assert hasattr(workflow, "invoke") - assert hasattr(workflow, "ainvoke") - - def test_integrated_workflow_creation(self): - workflow = create_integrated_analysis_and_planning_workflow() - assert workflow is not None - assert hasattr(workflow, "invoke") - assert hasattr(workflow, "ainvoke") - - def test_state_schema_compatibility(self): - state = create_initial_state( - user_id="test", - athlete_name="Test Athlete", - training_data={"generated_at_utc": "2024-01-02T00:00:00+00:00", "sources": {"strava": {}}}, - execution_id="test", - ) - - assert isinstance(state, dict) - assert "user_id" in state - assert "athlete_name" in state - assert "training_data" in state - - assert "season_plan" in state - assert "weekly_plan" in state - assert "weekly_plan_html" in state - assert "planning_context" in state - assert "transition_context" in state - - assert "metrics_outputs" in state - assert "activity_outputs" in state - assert "physiology_outputs" in state - assert "season_plan_html" in state - - -class TestWorkflowIntegration: - - @pytest.fixture - def minimal_valid_state(self) -> TrainingAnalysisState: - return create_initial_state( - user_id="test_user", - athlete_name="Test Athlete", - training_data={ - "generated_at_utc": "2024-01-02T00:00:00+00:00", - "sources": {"strava": {"training_load_history": []}}, - }, - planning_context="", - competitions=[], - current_date={}, - week_dates=[], - execution_id="test_integration", - ) - - def test_workflow_accepts_valid_state(self, minimal_valid_state): - """Test workflows can accept valid state structure.""" - planning_workflow = create_planning_workflow() - integrated_workflow = create_integrated_analysis_and_planning_workflow() - - assert planning_workflow is not None - assert integrated_workflow is not None - assert isinstance(minimal_valid_state, dict) - - def test_state_preserves_data_through_workflow(self, minimal_valid_state): - state = minimal_valid_state.copy() - - state["costs"] = [{"agent": "test1", "cost": 100}] - new_costs = [{"agent": "test2", "cost": 200}] - - combined_costs = state["costs"] + new_costs - assert len(combined_costs) == 2 - assert combined_costs[0]["agent"] == "test1" - assert combined_costs[1]["agent"] == "test2" - - state["plots"] = [{"plot_id": "plot1"}] - new_plots = [{"plot_id": "plot2"}] - - combined_plots = state["plots"] + new_plots - assert len(combined_plots) == 2 - - def test_workflow_node_count_stability(self): - planning_workflow = create_planning_workflow() - integrated_workflow = create_integrated_analysis_and_planning_workflow() - - assert planning_workflow is not None - assert integrated_workflow is not None - - -class TestWorkflowDataFlow: - - def test_planning_state_fields(self): - state = create_initial_state( - user_id="test", - athlete_name="Test", - training_data={"generated_at_utc": "2024-01-02T00:00:00+00:00", "sources": {"strava": {}}}, - execution_id="test", - ) - - for field in ["competitions", "current_date", "week_dates", "planning_context", "transition_context", "athlete_name"]: - assert field in state - - for field in ["season_plan", "weekly_plan"]: - assert field in state - - for field in ["metrics_outputs", "activity_outputs", "physiology_outputs"]: - assert field in state - - def test_state_update_functionality(self): - initial_state = create_initial_state( - user_id="test", - athlete_name="Test", - training_data={"generated_at_utc": "2024-01-02T00:00:00+00:00", "sources": {"strava": {}}}, - execution_id="test", - ) - - updated_state = {**initial_state, - "season_plan": "Test season plan content", - "weekly_plan": "Test weekly plan content", - "weekly_plan_html": "Test HTML" - } - - assert updated_state["season_plan"] == "Test season plan content" - assert updated_state["weekly_plan"] == "Test weekly plan content" - assert updated_state["weekly_plan_html"] == "Test HTML" - - assert updated_state["user_id"] == initial_state["user_id"] - assert updated_state["athlete_name"] == initial_state["athlete_name"] - - -class TestWorkflowImports: - - def test_all_planning_nodes_importable(self): - from services.ai.langgraph.nodes.data_integration_node import data_integration_node - from services.ai.langgraph.nodes.season_planner_node import season_planner_node - from services.ai.langgraph.nodes.weekly_planner_node import weekly_planner_node - - nodes = [ - season_planner_node, - data_integration_node, - weekly_planner_node, - ] - for node in nodes: - assert callable(node) - - def test_workflow_functions_importable(self): - from services.ai.langgraph.workflows.planning_workflow import ( - create_integrated_analysis_and_planning_workflow, - create_planning_workflow, - run_complete_analysis_and_planning, - run_weekly_planning, - ) - - functions = [ - create_planning_workflow, - create_integrated_analysis_and_planning_workflow, - run_weekly_planning, - run_complete_analysis_and_planning, - ] - for func in functions: - assert callable(func) - - def test_state_management_importable(self): - from services.ai.langgraph.state.training_analysis_state import ( - TrainingAnalysisState, - create_initial_state, - ) - - assert TrainingAnalysisState is not None - - state = create_initial_state( - user_id="test", - athlete_name="Test", - training_data={"generated_at_utc": "2024-01-02T00:00:00+00:00", "sources": {"strava": {}}}, - execution_id="test", - ) - assert isinstance(state, dict) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_langgraph_poc.py b/tests/test_langgraph_poc.py deleted file mode 100644 index fbd0e50..0000000 --- a/tests/test_langgraph_poc.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Minimal tests for LangGraph proof of concept.""" - -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from services.ai.langgraph.nodes.metrics_expert_node import metrics_expert_node -from services.ai.langgraph.state.training_analysis_state import create_initial_state -from services.ai.langgraph.workflows.analysis_workflow import create_analysis_workflow - - -@pytest.fixture -def sample_source_data(): - return { - "training_load_history": [{"date": "2024-01-01", "load": 150}], - "vo2_max_history": [{"date": "2024-01-01", "vo2_max": 45.2}], - "training_status": {"status": "productive"}, - } - -@pytest.fixture -def sample_training_data(sample_source_data): - return { - "generated_at_utc": "2024-01-02T00:00:00+00:00", - "sources": {"strava": sample_source_data}, - } - - -@pytest.fixture -def sample_state(sample_training_data): - return create_initial_state( - user_id="test_user", - athlete_name="Test Athlete", - training_data=sample_training_data, - execution_id="test_exec_123", - plotting_enabled=True - ) - - -def test_state_creation(sample_training_data): - state = create_initial_state( - user_id="user123", - athlete_name="John Doe", - training_data=sample_training_data, - execution_id="exec_123", - ) - - assert state["user_id"] == "user123" - assert state["athlete_name"] == "John Doe" - assert state["training_data"] == sample_training_data - assert state["metrics_outputs"] is None - assert state["plots"] == [] - - -@patch("services.ai.langgraph.config.langsmith_config.LangSmithConfig.setup_langsmith") -def test_workflow_creation(mock_langsmith): - assert create_analysis_workflow() is not None - mock_langsmith.assert_called_once() - - -@pytest.mark.asyncio -@patch("services.ai.model_config.ModelSelector.get_llm") -@patch("services.ai.tools.plotting.PlotStorage") -@patch("services.ai.langgraph.nodes.metrics_expert_node.retry_with_backoff", new_callable=AsyncMock) -async def test_metrics_expert_node_basic(mock_retry, mock_plot_storage, mock_get_llm, sample_state): - mock_llm = Mock() - mock_llm_with_tools = Mock() - - mock_response = Mock() - mock_response.content = "Test analysis result" - mock_response.tool_calls = [] - mock_llm_with_tools.ainvoke = AsyncMock(return_value=mock_response) - - mock_llm.bind_tools.return_value = mock_llm_with_tools - mock_get_llm.return_value = mock_llm - - mock_retry.return_value = "Test analysis result" - - mock_storage = Mock() - mock_storage.get_all_plots.return_value = {} - mock_plot_storage.return_value = mock_storage - - sample_state["metrics_summary"] = "Test metrics summary" - - result = await metrics_expert_node(sample_state) - - assert "metrics_outputs" in result - assert "plots" in result - assert "costs" in result - assert result["metrics_outputs"] == "Test analysis result" - - mock_llm.bind_tools.assert_called_once() - mock_get_llm.assert_called_once() diff --git a/tests/test_local_readiness.py b/tests/test_local_readiness.py index f584796..90dfa3c 100644 --- a/tests/test_local_readiness.py +++ b/tests/test_local_readiness.py @@ -1,18 +1,7 @@ from api.services import local_readiness -from core.config import AIMode, Config -def test_openai_modes_require_openai_key(monkeypatch): - monkeypatch.setattr(local_readiness, "get_config", lambda: Config(ai_mode=AIMode.COST_EFFECTIVE)) - +def test_runtime_requires_openai_key(): assert local_readiness.format_llm_provider_key_names() == "OPENAI_API_KEY" assert local_readiness.has_llm_provider_key({"OPENAI_API_KEY": "sk-test"}) - assert not local_readiness.has_llm_provider_key({"ANTHROPIC_API_KEY": "sk-ant-api03-test"}) - - -def test_anthropic_mode_requires_anthropic_key(monkeypatch): - monkeypatch.setattr(local_readiness, "get_config", lambda: Config(ai_mode=AIMode.ANTHROPIC)) - - assert local_readiness.format_llm_provider_key_names() == "ANTHROPIC_API_KEY" - assert local_readiness.has_llm_provider_key({"ANTHROPIC_API_KEY": "sk-ant-api03-test"}) - assert not local_readiness.has_llm_provider_key({"OPENAI_API_KEY": "sk-test"}) + assert not local_readiness.has_llm_provider_key({"UNRELATED_API_KEY": "test"}) diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 4449f6d..d9bb3ad 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -5,6 +5,7 @@ from core.config import AIMode, Config from services.ai import model_config from services.ai.ai_settings import AgentRole, AISettings +from services.ai.head_coach.run_profiles import RunProfileName, get_run_profile from services.ai.model_config import ModelSelector @@ -17,80 +18,47 @@ def get_model_for_role(self, _: AgentRole) -> str: GPT_5_5_SEARCH_ROLES = { - AgentRole.METRICS_EXPERT, - AgentRole.PHYSIOLOGY_EXPERT, - AgentRole.ACTIVITY_EXPERT, - AgentRole.WEEKLY_PLANNER, - AgentRole.SEASON_PLANNER, - AgentRole.WEEKLY_RECAP, - AgentRole.DAILY_UPDATE, + AgentRole.SPECIALIST, } -@pytest.mark.parametrize( - ("model_name", "api_key_field", "expected_client"), - [ - ("claude-4", "anthropic_api_key", "ChatAnthropic"), - ("gpt-4o", "openai_api_key", "ChatOpenAI"), - ], -) -def test_prefers_direct_api_when_key_available(monkeypatch, model_name, api_key_field, expected_client): - api_key_values = { - "anthropic_api_key": "sk-ant-api03-test", - "openai_api_key": "sk-test", - } - config_dict = { - api_key_field: api_key_values[api_key_field], - "ai_mode": AIMode.STANDARD, - } - from typing import Any, cast - - config = Config(**cast("dict[str, Any]", config_dict)) +def test_uses_openai_client_when_key_available(monkeypatch): + config = Config(openai_api_key="sk-test", ai_mode=AIMode.STANDARD) monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings(model_name)) + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-4o")) captured = {} - def fake_chat_anthropic(**kwargs): - captured.update(kwargs) - captured["client"] = "ChatAnthropic" - return types.SimpleNamespace(**kwargs) - def fake_chat_openai(**kwargs): captured.update(kwargs) captured["client"] = "ChatOpenAI" return types.SimpleNamespace(**kwargs) - monkeypatch.setattr(model_config, "ChatAnthropic", fake_chat_anthropic) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - ModelSelector.get_llm(AgentRole.SUMMARIZER) + ModelSelector.get_llm(AgentRole.HEAD_COACH) - assert captured["api_key"] == api_key_values[api_key_field] - assert captured["client"] == expected_client - if expected_client == "ChatOpenAI": - assert captured["base_url"] == "https://api.openai.com/v1" - else: - assert captured["model"] == "claude-sonnet-4-6" + assert captured["api_key"] == "sk-test" + assert captured["client"] == "ChatOpenAI" + assert captured["base_url"] == "https://api.openai.com/v1" -def test_missing_both_direct_and_openrouter_keys_raises(monkeypatch): +def test_missing_openai_key_raises(monkeypatch): config = Config(ai_mode=AIMode.STANDARD) monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("claude-4")) + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-4o")) monkeypatch.setattr(model_config, "ChatOpenAI", lambda **_kwargs: None) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) with pytest.raises(RuntimeError, match="API key"): - ModelSelector.get_llm(AgentRole.SUMMARIZER) + ModelSelector.get_llm(AgentRole.HEAD_COACH) @pytest.mark.parametrize("role", AISettings(mode=AIMode.STANDARD).model_assignments[AIMode.STANDARD]) -def test_gpt_5_5_search_roles_use_xhigh_reasoning_effort(monkeypatch, role: AgentRole): +def test_gpt_5_6_sol_search_roles_use_xhigh_reasoning_effort(monkeypatch, role: AgentRole): config = Config(ai_mode=AIMode.STANDARD, openai_api_key="sk-test") monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.5-search")) + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.6-sol-search")) captured = {} @@ -99,18 +67,17 @@ def fake_chat_openai(**kwargs): return types.SimpleNamespace(**kwargs) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) ModelSelector.get_llm(role) assert captured["reasoning"]["effort"] == "xhigh" -def test_standard_role_mappings_use_gpt_5_5_search_for_all_roles(): +def test_standard_role_mappings_use_gpt_5_6_sol_search_for_all_roles(): settings = AISettings(mode=AIMode.STANDARD) for role in settings.model_assignments[AIMode.STANDARD]: - assert settings.get_model_for_role(role) == "gpt-5.5-search" + assert settings.get_model_for_role(role) == "gpt-5.6-sol-search" @pytest.mark.parametrize("mode", [AIMode.COST_EFFECTIVE, AIMode.DEVELOPMENT, AIMode.PRO]) @@ -122,32 +89,22 @@ def test_non_standard_role_mappings_keep_gpt_5_5_family(mode: AIMode): assert settings.get_model_for_role(role) == expected_model -def test_anthropic_role_mappings_use_claude_family(): - settings = AISettings(mode=AIMode.ANTHROPIC) - - for role in settings.model_assignments[AIMode.ANTHROPIC]: - assert settings.get_model_for_role(role) == "claude-4" - - -@pytest.mark.parametrize("role", [AgentRole.ANALYSIS_FORMATTER, AgentRole.PLAN_FORMATTER]) -def test_standard_formatter_model_mapping_uses_gpt_5_5_search(role: AgentRole): - settings = AISettings(mode=AIMode.STANDARD) - - assert settings.get_model_for_role(role) == "gpt-5.5-search" - - @pytest.mark.parametrize( - ("alias_name", "expected_model_name"), + ("alias_name", "expected_model_name", "expected_effort"), [ - ("gpt-5", "gpt-5.5"), - ("gpt-5-search", "gpt-5.5"), - ("gpt-5.5", "gpt-5.5"), - ("gpt-5.5-search", "gpt-5.5"), - ("gpt-5.4", "gpt-5.4"), - ("gpt-5.4-search", "gpt-5.4"), + ("gpt-5", "gpt-5.5", "high"), + ("gpt-5.6-sol", "gpt-5.6-sol", "high"), + ("gpt-5.6-sol-search", "gpt-5.6-sol", "xhigh"), + ("gpt-5-search", "gpt-5.5", "high"), + ("gpt-5.5", "gpt-5.5", "high"), + ("gpt-5.5-search", "gpt-5.5", "xhigh"), + ("gpt-5.4", "gpt-5.4", "high"), + ("gpt-5.4-search", "gpt-5.4", "xhigh"), ], ) -def test_gpt_5_aliases_resolve_to_configured_model_name(monkeypatch, alias_name: str, expected_model_name: str): +def test_gpt_5_aliases_resolve_to_configured_model_name( + monkeypatch, alias_name: str, expected_model_name: str, expected_effort: str +): config = Config(ai_mode=AIMode.STANDARD, openai_api_key="sk-test") monkeypatch.setattr(model_config, "get_config", lambda: config) monkeypatch.setattr(model_config, "ai_settings", _StubSettings(alias_name)) @@ -159,92 +116,20 @@ def fake_chat_openai(**kwargs): return types.SimpleNamespace(**kwargs) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) ModelSelector.get_llm(AgentRole.COACH_TRIAGE) assert captured["model"] == expected_model_name assert captured["base_url"] == "https://api.openai.com/v1" - assert captured["reasoning"]["effort"] == "xhigh" - - -def test_claude_opus_4_8_max_configures_thinking(monkeypatch): - config = Config(ai_mode=AIMode.STANDARD, anthropic_api_key="sk-ant-api03-test") - monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("claude-opus-4.8-max")) - - captured = {} - - def fake_chat_anthropic(**kwargs): - captured.update(kwargs) - return types.SimpleNamespace(**kwargs) - - monkeypatch.setattr(model_config, "ChatAnthropic", fake_chat_anthropic) - monkeypatch.setattr(model_config, "ChatOpenAI", lambda **_kwargs: None) - - ModelSelector.get_llm(AgentRole.COACH) - - assert captured["model"] == "claude-opus-4-8" - assert captured["max_tokens"] == 32000 - assert captured["thinking"] == {"type": "adaptive"} - assert captured["output_config"] == {"effort": "max"} - assert "reasoning" not in captured - assert "model_kwargs" not in captured - - -@pytest.mark.parametrize("role", [AgentRole.SEASON_PLANNER, AgentRole.WEEKLY_PLANNER]) -def test_planner_roles_use_64k_output_tokens_for_claude(monkeypatch, role: AgentRole): - config = Config(ai_mode=AIMode.STANDARD, anthropic_api_key="sk-ant-api03-test") - monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("claude-opus-4.8-max")) - - captured = {} - - def fake_chat_anthropic(**kwargs): - captured.update(kwargs) - return types.SimpleNamespace(**kwargs) - - monkeypatch.setattr(model_config, "ChatAnthropic", fake_chat_anthropic) - monkeypatch.setattr(model_config, "ChatOpenAI", lambda **_kwargs: None) - - ModelSelector.get_llm(role) - - assert captured["model"] == "claude-opus-4-8" - assert captured["max_tokens"] == 64000 - assert captured["thinking"] == {"type": "adaptive"} - assert captured["output_config"] == {"effort": "max"} - - -@pytest.mark.parametrize("role", [AgentRole.ANALYSIS_FORMATTER, AgentRole.PLAN_FORMATTER]) -def test_claude_formatter_roles_disable_thinking(monkeypatch, role: AgentRole): - config = Config(ai_mode=AIMode.STANDARD, anthropic_api_key="sk-ant-api03-test") - monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("claude-opus-4.8-max")) - - captured = {} - - def fake_chat_anthropic(**kwargs): - captured.update(kwargs) - return types.SimpleNamespace(**kwargs) - - monkeypatch.setattr(model_config, "ChatAnthropic", fake_chat_anthropic) - monkeypatch.setattr(model_config, "ChatOpenAI", lambda **_kwargs: None) - - ModelSelector.get_llm(role) - - assert captured["model"] == "claude-opus-4-8" - assert captured["max_tokens"] == 64000 - assert "thinking" not in captured - assert "output_config" not in captured - assert "reasoning" not in captured - assert "model_kwargs" not in captured + assert captured["reasoning"]["effort"] == expected_effort + assert "tools" not in captured.get("model_kwargs", {}) + assert "include" not in captured -@pytest.mark.parametrize("role", [AgentRole.ANALYSIS_FORMATTER, AgentRole.PLAN_FORMATTER]) -def test_gpt_formatter_roles_use_64k_output_tokens(monkeypatch, role: AgentRole): +def test_search_models_pass_include_explicitly(monkeypatch): config = Config(ai_mode=AIMode.STANDARD, openai_api_key="sk-test") monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.5")) + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.6-sol-search")) captured = {} @@ -253,20 +138,34 @@ def fake_chat_openai(**kwargs): return types.SimpleNamespace(**kwargs) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) - ModelSelector.get_llm(role) + ModelSelector.get_llm(AgentRole.SPECIALIST) - assert captured["model"] == "gpt-5.5" - assert captured["reasoning"]["effort"] == "xhigh" - assert captured["model_kwargs"]["max_output_tokens"] == 64000 + assert captured["include"] == ["web_search_call.action.sources"] + assert captured["model_kwargs"]["tools"] == [{"type": "web_search"}] + assert "include" not in captured["model_kwargs"] -def test_weekly_planner_openai_search_uses_64k_and_preserves_tools(monkeypatch): +@pytest.mark.parametrize( + ("profile_name", "expected_effort"), + [ + (RunProfileName.INITIAL_PLANNING, "medium"), + (RunProfileName.MATERIAL_REPLANNING, "xhigh"), + (RunProfileName.COACH_TURN, "medium"), + (RunProfileName.WEEKLY_RECAP, "high"), + (RunProfileName.DAILY_ADAPTATION, "high"), + (RunProfileName.MEMORY_EXTRACTION, "low"), + (RunProfileName.UI_COMPOSER, "low"), + ], +) +def test_head_coach_profile_overrides_global_xhigh_default( + monkeypatch, + profile_name: RunProfileName, + expected_effort: str, +): config = Config(ai_mode=AIMode.STANDARD, openai_api_key="sk-test") monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.5-search")) - + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.6-sol-search")) captured = {} def fake_chat_openai(**kwargs): @@ -274,19 +173,23 @@ def fake_chat_openai(**kwargs): return types.SimpleNamespace(**kwargs) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) + profile = get_run_profile(profile_name) - ModelSelector.get_llm(AgentRole.WEEKLY_PLANNER) + ModelSelector.get_llm( + profile.model_role, + reasoning_effort=profile.reasoning_effort, + enable_native_web_search=profile.enable_native_web_search, + ) - assert captured["model_kwargs"]["max_output_tokens"] == 64000 - assert captured["model_kwargs"]["tools"] == [{"type": "web_search"}] + assert captured["reasoning"]["effort"] == expected_effort + assert "tools" not in captured.get("model_kwargs", {}) + assert "include" not in captured -def test_search_models_pass_include_explicitly(monkeypatch): +def test_research_specialist_explicitly_keeps_native_web_search(monkeypatch): config = Config(ai_mode=AIMode.STANDARD, openai_api_key="sk-test") monkeypatch.setattr(model_config, "get_config", lambda: config) - monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.5-search")) - + monkeypatch.setattr(model_config, "ai_settings", _StubSettings("gpt-5.6-sol-search")) captured = {} def fake_chat_openai(**kwargs): @@ -294,10 +197,14 @@ def fake_chat_openai(**kwargs): return types.SimpleNamespace(**kwargs) monkeypatch.setattr(model_config, "ChatOpenAI", fake_chat_openai) - monkeypatch.setattr(model_config, "ChatAnthropic", lambda **_kwargs: None) + profile = get_run_profile(RunProfileName.RESEARCH_SPECIALIST) - ModelSelector.get_llm(AgentRole.METRICS_EXPERT) + ModelSelector.get_llm( + profile.model_role, + reasoning_effort=profile.reasoning_effort, + enable_native_web_search=profile.enable_native_web_search, + ) - assert captured["include"] == ["web_search_call.action.sources"] + assert captured["reasoning"]["effort"] == "xhigh" assert captured["model_kwargs"]["tools"] == [{"type": "web_search"}] - assert "include" not in captured["model_kwargs"] + assert captured["include"] == ["web_search_call.action.sources"] diff --git a/tests/test_no_legacy_agent_path.py b/tests/test_no_legacy_agent_path.py new file mode 100644 index 0000000..316de8d --- /dev/null +++ b/tests/test_no_legacy_agent_path.py @@ -0,0 +1,31 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_provider_shaped_legacy_runtime_is_absent() -> None: + removed_paths = [ + ROOT / "services/ai/langgraph/workflows/analysis_workflow.py", + ROOT / "services/ai/langgraph/workflows/planning_workflow.py", + ROOT / "services/ai/langgraph/nodes/orchestrator_node.py", + ROOT / "services/ai/langgraph/nodes/analysis_formatter_node.py", + ROOT / "services/ai/langgraph/nodes/season_formatter_node.py", + ROOT / "services/ai/langgraph/nodes/weekly_formatter_node.py", + ROOT / "services/ai/langgraph/nodes/tool_calling_helper.py", + ROOT / "services/ai/coach/plan_modifier_agent.py", + ] + + assert all(not path.exists() for path in removed_paths) + + +def test_plan_generation_has_one_runtime_owner() -> None: + api_source = (ROOT / "api/routers/analysis.py").read_text(encoding="utf-8") + worker_source = (ROOT / "worker/tasks.py").read_text(encoding="utf-8") + factory_source = (ROOT / "services/ai/head_coach/agent.py").read_text(encoding="utf-8") + + assert '"_workflow_version": "head_coach_v1"' in api_source + assert "legacy_v1" not in api_source + assert "run_complete_analysis_and_planning" not in worker_source + assert "handle_tool_calling_in_node" not in worker_source + assert "create_agent(" in factory_source + assert "create_react_agent" not in factory_source diff --git a/tests/test_ongoing_providers.py b/tests/test_ongoing_providers.py deleted file mode 100644 index 1373f32..0000000 --- a/tests/test_ongoing_providers.py +++ /dev/null @@ -1,194 +0,0 @@ -from datetime import UTC, date, datetime -from typing import Any, cast - -import pytest -from fastapi import HTTPException - -from api.models.credentials import StravaCredentials, WhoopCredentials -from api.services import ongoing_providers -from api.services.ongoing_tools import build_ongoing_tool_registry - - -class _ScalarResult: - def __init__(self, value): - self._value = value - - def scalar_one_or_none(self): - return self._value - - -class _StubDbSession: - pass - - -@pytest.mark.asyncio -async def test_strava_provider_aggregates_training_load_history(): - provider = ongoing_providers.StravaProvider(db=cast("Any", _StubDbSession()), user_id="user-1") - - async def _fake_get_access_token() -> str: - return "token" - - def _fake_list_activities_sync(*, access_token: str, after: int, before: int) -> list[dict]: - _ = (access_token, after, before) - return [ - { - "id": 1, - "sport_type": "Run", - "start_date_local": "2026-03-05T07:00:00Z", - "distance": 10000, - "moving_time": 3600, - "relative_effort": 65, - }, - { - "id": 2, - "sport_type": "Ride", - "start_date_local": "2026-03-05T15:00:00Z", - "distance": 30000, - "moving_time": 5400, - "suffer_score": 40, - }, - ] - - provider._get_access_token = _fake_get_access_token # type: ignore[method-assign] - provider._list_activities_sync = _fake_list_activities_sync # type: ignore[method-assign] - - payload = await provider.get_training_load_history(days=7) - - assert payload == [ - { - "date": "2026-03-05", - "activity_count": 2, - "relative_effort_total": 65.0, - "suffer_score_total": 40.0, - "moving_time_minutes_total": 150.0, - "distance_m_total": 40000.0, - "load_type": "strava_relative_effort", - "load_value": 65.0, - } - ] - - -@pytest.mark.asyncio -async def test_strava_provider_paginates_recent_activities(monkeypatch): - provider = ongoing_providers.StravaProvider(db=cast("Any", _StubDbSession()), user_id="user-1") - - async def _fake_get_access_token() -> str: - return "token" - - provider._get_access_token = _fake_get_access_token # type: ignore[method-assign] - - class _FakeClient: - def __init__(self): - self.calls: list[int] = [] - - def list_activities(self, *, page: int, per_page: int, after: int, before: int) -> list[dict]: - _ = (per_page, after, before) - self.calls.append(page) - if page == 1: - return [{"id": page * 1000 + idx, "sport_type": "Run"} for idx in range(100)] - if page == 2: - return [{"id": page * 1000 + idx, "sport_type": "Run"} for idx in range(25)] - return [] - - def close(self) -> None: - return None - - fake_client = _FakeClient() - monkeypatch.setattr(ongoing_providers, "StravaApiClient", lambda access_token: fake_client) - activities = await provider.get_recent_activities(date(2026, 3, 1), date(2026, 3, 7)) - - assert len(activities) == 125 - assert fake_client.calls == [1, 2] - - -@pytest.mark.asyncio -async def test_build_ongoing_tool_registry_allows_empty_providers_when_optional(monkeypatch): - async def _raise_missing(*_args, **_kwargs): - raise HTTPException(status_code=404, detail="Not connected") - - monkeypatch.setattr("api.services.ongoing_tools.build_ongoing_strava_provider", _raise_missing) - monkeypatch.setattr("api.services.ongoing_tools.build_ongoing_whoop_provider", _raise_missing) - - async with build_ongoing_tool_registry( - cast("Any", object()), - user_id="user-1", - require_training_provider=False, - ) as registry: - snapshot = registry.get_observability_snapshot() - providers = snapshot["provider"]["training_providers"] - assert providers["strava"]["available"] is False - assert providers["whoop"]["available"] is False - - with pytest.raises(HTTPException) as exc: - async with build_ongoing_tool_registry( - cast("Any", object()), - user_id="user-1", - require_training_provider=True, - ): - raise AssertionError("Should not enter context when a provider is required and none are connected") - assert exc.value.status_code == 404 - - -@pytest.mark.asyncio -async def test_whoop_provider_caches_access_token_failure(monkeypatch): - provider = ongoing_providers.WhoopProvider(db=cast("Any", _StubDbSession()), user_id="user-1") - token_calls = 0 - - async def _fake_ensure_valid_access_token(_db, *, user_id): - nonlocal token_calls - _ = user_id - token_calls += 1 - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") - - monkeypatch.setattr(ongoing_providers, "ensure_valid_access_token", _fake_ensure_valid_access_token) - - with pytest.raises(HTTPException) as first_exc: - await provider.get_training_load_history(days=7) - with pytest.raises(HTTPException) as second_exc: - await provider.get_recent_activities(date(2026, 3, 1), date(2026, 3, 2)) - - assert first_exc.value.status_code == 401 - assert second_exc.value.status_code == 401 - assert token_calls == 1 - - -@pytest.mark.asyncio -async def test_build_ongoing_strava_provider_returns_strava_provider(): - class _Db: - async def execute(self, statement): - assert "FROM strava_credentials" in str(statement) - return _ScalarResult( - StravaCredentials( - user_id="user-1", - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 22, tzinfo=UTC), - scope="activity:read_all", - strava_athlete_id=4242, - ) - ) - - provider = await ongoing_providers.build_ongoing_strava_provider(cast("Any", _Db()), user_id="user-1") - - assert isinstance(provider, ongoing_providers.StravaProvider) - - -@pytest.mark.asyncio -async def test_build_ongoing_whoop_provider_returns_whoop_provider(): - class _Db: - async def execute(self, statement): - assert "FROM whoop_credentials" in str(statement) - return _ScalarResult( - WhoopCredentials( - user_id="user-1", - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 22, tzinfo=UTC), - scope="offline", - whoop_user_id=42, - ) - ) - - provider = await ongoing_providers.build_ongoing_whoop_provider(cast("Any", _Db()), user_id="user-1") - - assert isinstance(provider, ongoing_providers.WhoopProvider) diff --git a/tests/test_ongoing_tools.py b/tests/test_ongoing_tools.py index 97c4a74..828366b 100644 --- a/tests/test_ongoing_tools.py +++ b/tests/test_ongoing_tools.py @@ -1,55 +1,18 @@ import uuid -from datetime import UTC, date, datetime +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import HTTPException from api.services.ongoing_tools import OngoingToolRegistry -@pytest.mark.asyncio -async def test_get_current_analysis_returns_rendered_payload_when_present(): - user_id = uuid.uuid4() - active = SimpleNamespace( - analysis_data={"type": "analysis", "schema_version": 2, "analysis_id": "a1"}, - version=4, - updated_at=datetime(2026, 2, 27, 12, 0, tzinfo=UTC), - ) - row = MagicMock() - row.scalar_one_or_none.return_value = active - db = AsyncMock() - db.execute.return_value = row - registry = OngoingToolRegistry(db=db, user_id=user_id, providers={}) - - payload = await registry.get_current_analysis() - - assert payload["analysis_id"] == "a1" - assert payload["version"] == 4 - assert payload["updated_at"] == "2026-02-27T12:00:00+00:00" - - -@pytest.mark.asyncio -async def test_get_current_analysis_returns_empty_payload_when_missing(): - user_id = uuid.uuid4() - row = MagicMock() - row.scalar_one_or_none.return_value = None - db = AsyncMock() - db.execute.return_value = row - registry = OngoingToolRegistry(db=db, user_id=user_id, providers={}) - - payload = await registry.get_current_analysis() - - assert payload == {} - - @pytest.mark.asyncio async def test_get_athlete_profile_includes_memory_freshness_metadata(): - user_id = uuid.uuid4() updated_at = "2026-02-20T12:00:00+00:00" - row = MagicMock() - row.scalar_one_or_none.return_value = SimpleNamespace( + user_row = MagicMock() + user_row.scalar_one_or_none.return_value = SimpleNamespace( memory_summary="Athlete responds well to short cues.", athlete_model={ "goal_state": "build", @@ -65,131 +28,39 @@ async def test_get_athlete_profile_includes_memory_freshness_metadata(): "_meta": {"updated_at": updated_at}, }, ) + profile_row = MagicMock() + profile_row.scalar_one_or_none.return_value = { + "experience": "intermediate", + "availability": ["Monday", "Thursday"], + } db = AsyncMock() - db.execute.return_value = row - registry = OngoingToolRegistry(db=db, user_id=user_id, providers={}) + db.execute.side_effect = [user_row, profile_row] + registry = OngoingToolRegistry(db=db, user_id=uuid.uuid4()) payload = await registry.get_athlete_profile() assert payload["memory_summary"] == "Athlete responds well to short cues." + assert payload["profile"]["experience"] == "intermediate" assert payload["athlete_model"]["goal_state"] == "build" assert payload["transient_state_notes"][0]["topic"] == "illness" - assert payload["transient_state_notes"][0]["status"] == "active" assert payload["memory_updated_at"] == updated_at - expected_age = max((datetime.now(UTC) - datetime.fromisoformat(updated_at)).days, 0) - assert payload["memory_age_days"] == expected_age - - -def test_create_langchain_tools_exposes_context_retrieval_tools(): - registry = OngoingToolRegistry(db=AsyncMock(), user_id=uuid.uuid4(), providers={}) - - tool_names = {tool.name for tool in registry.create_langchain_tools()} - - assert "get_current_analysis" in tool_names - assert "get_current_weekly_plan" in tool_names - assert "get_current_season_plan" in tool_names - assert "get_expert_output" in tool_names - assert "get_athlete_profile" in tool_names - - -def test_get_observability_snapshot_includes_provider_availability(): - registry = OngoingToolRegistry(db=AsyncMock(), user_id=uuid.uuid4(), providers={}) - - snapshot = registry.get_observability_snapshot() + assert payload["memory_age_days"] == max((datetime.now(UTC) - datetime.fromisoformat(updated_at)).days, 0) - providers = snapshot["provider"]["training_providers"] - assert providers["strava"]["available"] is False - assert providers["whoop"]["available"] is False - assert snapshot["evidence_profile"]["connected_mode"] == "none" - assert snapshot["evidence_profile"]["claims_policy"]["can_make_readiness_claims"] is False +def test_registry_exposes_only_athlete_owned_local_sources(): + registry = OngoingToolRegistry(db=AsyncMock(), user_id=uuid.uuid4()) -class _GoodProvider: - async def get_recent_activities(self, date_from: date, date_to: date, sport_filters=None) -> list[dict]: - _ = (date_from, date_to, sport_filters) - return [ - { - "activity_id": 123, - "activity_type": "cycling", - "activity_name": "Aerobic ride", - "start_time": "2026-03-06T07:00:00Z", - } - ] + assert {tool.name for tool in registry.create_langchain_tools()} == { + "get_athlete_profile", + "get_current_season_plan", + "get_current_weekly_plan", + "get_upcoming_competitions", + } + assert registry.get_observability_snapshot()["source_of_truth"] == "local_athlete_owned" - async def get_training_load_history(self, days: int) -> list[dict]: - _ = days - return [{"date": "2026-03-06", "daily_load": 88}] - - async def get_recovery_readiness_signals(self, days: int) -> dict: - _ = days - return {"recoveries": [{"score": 78}]} - - async def get_activity_detail(self, activity_id: int | str) -> dict | None: - _ = activity_id - return None - - -class _ExpiredWhoopProvider: - async def get_recent_activities(self, date_from: date, date_to: date, sport_filters=None) -> list[dict]: - _ = (date_from, date_to, sport_filters) - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") - - async def get_training_load_history(self, days: int) -> list[dict]: - _ = days - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") - - async def get_recovery_readiness_signals(self, days: int) -> dict: - _ = days - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") - - async def get_activity_detail(self, activity_id: int | str) -> dict | None: - _ = activity_id - raise HTTPException(status_code=401, detail="WHOOP connection expired. Please reconnect WHOOP.") - - -@pytest.mark.asyncio -async def test_training_snapshot_degrades_when_whoop_expires(monkeypatch): - registry = OngoingToolRegistry( - db=AsyncMock(), - user_id=uuid.uuid4(), - providers={ - "strava": _GoodProvider(), - "whoop": _ExpiredWhoopProvider(), - }, - ) - - registry.get_current_weekly_plan = AsyncMock(return_value={"weeks": []}) # type: ignore[method-assign] - registry.get_upcoming_competitions = AsyncMock(return_value=[]) # type: ignore[method-assign] - - payload = await registry.get_training_snapshot() - - assert payload["sessions_7d"] == 1 - assert payload["sessions_7d_by_source"] == {"strava": 1} - assert payload["provider_status"]["strava"]["available"] is True - assert payload["provider_status"]["whoop"]["available"] is False - assert payload["provider_status"]["whoop"]["status_code"] == 401 - assert "expired" in str(payload["provider_status"]["whoop"]["last_error"]).lower() - assert payload["evidence_profile"]["connected_mode"] == "strava_only" - assert payload["evidence_profile"]["dimensions"]["readiness_guidance"]["availability"] == "proxy_only" - assert payload["evidence_profile"]["claims_policy"]["can_make_activity_completeness_claims"] is True - assert payload["evidence_profile"]["claims_policy"]["can_make_readiness_claims"] is False - - -@pytest.mark.asyncio -async def test_recovery_signals_degrade_when_whoop_expires(): - registry = OngoingToolRegistry( - db=AsyncMock(), - user_id=uuid.uuid4(), - providers={ - "strava": _GoodProvider(), - "whoop": _ExpiredWhoopProvider(), - }, - ) - payload = await registry.get_recovery_readiness_signals(days=7) +def test_registry_rejects_removed_provider_tool_names(): + registry = OngoingToolRegistry(db=AsyncMock(), user_id=uuid.uuid4()) - assert set(payload["sources"].keys()) == {"strava"} - assert payload["provider_status"]["whoop"]["available"] is False - assert payload["provider_status"]["whoop"]["status_code"] == 401 - assert payload["evidence_profile"]["connected_mode"] == "strava_only" - assert payload["evidence_profile"]["claims_policy"]["should_frame_guidance_as_proxy_based"] is True + with pytest.raises(ValueError, match="Unknown ongoing tool names"): + registry.create_langchain_tools(allowed_tool_names={"get_recent_activities"}) diff --git a/tests/test_orchestrator_hitl_logging.py b/tests/test_orchestrator_hitl_logging.py deleted file mode 100644 index 85d3652..0000000 --- a/tests/test_orchestrator_hitl_logging.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging - -import pytest - -from services.ai.langgraph.nodes.orchestrator_node import ConsoleInteractionProvider - - -@pytest.mark.unit -def test_console_interaction_provider_does_not_log_raw_answers(monkeypatch, caplog): - provider = ConsoleInteractionProvider() - sensitive_answer = "I had chest pain and my phone number is 555-0101" - monkeypatch.setattr("builtins.input", lambda _prompt: sensitive_answer) - - questions = [ - { - "agent": "metrics_expert", - "question": { - "message": "How did your recovery feel today?", - "context": "Optional context", - }, - } - ] - - with caplog.at_level(logging.INFO, logger="services.ai.langgraph.nodes.orchestrator_node"): - answers = provider.collect_answers(questions, "Analysis") - - assert answers[0]["answer"] == sensitive_answer - assert sensitive_answer not in caplog.text - assert "answer_chars=" in caplog.text diff --git a/tests/test_plotting_tool_integration.py b/tests/test_plotting_tool_integration.py deleted file mode 100644 index f663e2e..0000000 --- a/tests/test_plotting_tool_integration.py +++ /dev/null @@ -1,127 +0,0 @@ -import pytest - -from services.ai.tools.plotting.langgraph_plotting_tool import create_plotting_tools -from services.ai.tools.plotting.plot_storage import PlotStorage - - -class TestPlottingToolIntegration: - - def test_langchain_tool_creation(self): - plot_storage = PlotStorage("test_execution") - - plotting_tool = create_plotting_tools(plot_storage, agent_name="test") - - assert plotting_tool.name == "python_plotting_tool" - - assert "Execute complete Python code" in plotting_tool.description - - @pytest.mark.asyncio - async def test_tool_invocation_denied_by_default(self, monkeypatch): - monkeypatch.delenv("ENABLE_UNSAFE_PLOTTING_EXECUTION", raising=False) - - plot_storage = PlotStorage("test_execution") - plotting_tool = create_plotting_tools(plot_storage, agent_name="test") - - test_code = """ -import plotly.graph_objects as go -fig = go.Figure() -fig.add_trace(go.Scatter(x=[1, 2, 3], y=[4, 5, 6], name='Test Data')) -fig.update_layout(title='Test Plot') -""" - - result = await plotting_tool.ainvoke( - {"python_code": test_code, "description": "Test plot for integration"} - ) - - assert result["ok"] is False - assert "disabled by default for security" in result["error"] - - @pytest.mark.asyncio - async def test_tool_invocation(self, monkeypatch): - monkeypatch.setenv("ENABLE_UNSAFE_PLOTTING_EXECUTION", "true") - - plot_storage = PlotStorage("test_execution") - plotting_tool = create_plotting_tools(plot_storage, agent_name="test") - - test_code = """ -import plotly.graph_objects as go -fig = go.Figure() -fig.add_trace(go.Scatter(x=[1, 2, 3], y=[4, 5, 6], name='Test Data')) -fig.update_layout(title='Test Plot') -""" - - result = await plotting_tool.ainvoke( - {"python_code": test_code, "description": "Test plot for integration"} - ) - - assert result["ok"] is True - assert "Plot created successfully" in result["message"] - assert "[PLOT:" in result["message"] - assert "plot_id" in result - - @pytest.mark.asyncio - async def test_model_tool_binding(self, monkeypatch): - from unittest.mock import Mock - - from services.ai.ai_settings import AgentRole - from services.ai.model_config import ModelSelector - - plot_storage = PlotStorage("test_execution") - plotting_tool = create_plotting_tools(plot_storage, agent_name="test") - - mock_llm = Mock() - mock_llm_with_tools = Mock() - mock_llm_with_tools.kwargs = {"tools": [plotting_tool]} - mock_llm.bind_tools.return_value = mock_llm_with_tools - - monkeypatch.setattr(ModelSelector, "get_llm", lambda role: mock_llm) - - llm = ModelSelector.get_llm(AgentRole.METRICS_EXPERT) - llm_with_tools = llm.bind_tools([plotting_tool]) - - assert hasattr(llm_with_tools, "kwargs") and "tools" in llm_with_tools.kwargs - assert len(llm_with_tools.kwargs["tools"]) == 1 - assert plotting_tool.name == "python_plotting_tool" - - def test_tools_condition_compatibility(self): - from langchain_core.messages import AIMessage - from langgraph.prebuilt import tools_condition - - message_without_tools = AIMessage(content="This is a regular response") - - result = tools_condition({"messages": [message_without_tools]}) - assert result == "__end__" - - message_with_tools = AIMessage( - content="I'll create a plot for you", - tool_calls=[ - { - "name": "python_plotting_tool", - "args": {"python_code": "test", "description": "test"}, - "id": "test_id", - } - ], - ) - - result = tools_condition({"messages": [message_with_tools]}) - assert result == "tools" - - def test_canonical_pattern_components(self): - from langgraph.graph import StateGraph - from langgraph.prebuilt import ToolNode - - plot_storage = PlotStorage("test_execution") - plotting_tool = create_plotting_tools(plot_storage, "test") - tools = [plotting_tool] - - tool_node = ToolNode(tools) - assert tool_node is not None - - from services.ai.langgraph.state.training_analysis_state import TrainingAnalysisState - - workflow = StateGraph(TrainingAnalysisState) - assert workflow is not None - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_provider_free_release_contract.py b/tests/test_provider_free_release_contract.py new file mode 100644 index 0000000..8f8efbc --- /dev/null +++ b/tests/test_provider_free_release_contract.py @@ -0,0 +1,84 @@ +from pathlib import Path + +import pytest + +from services.ai.evals.head_coach_eval import load_eval_suite + + +@pytest.mark.unit +def test_public_api_excludes_training_data_provider_routes(monkeypatch): + monkeypatch.setenv("WEB_APP_URL", "http://localhost:3000") + + from api.config import get_settings + from api.main import create_app + + get_settings.cache_clear() + route_paths = {path for route in create_app().routes if (path := getattr(route, "path", None)) is not None} + + excluded_paths = { + "/api/oauth/strava/start", + "/api/oauth/strava/callback", + "/api/oauth/whoop/start", + "/api/oauth/whoop/callback", + "/api/integrations/status", + "/api/account/strava/disconnect", + "/api/account/whoop/disconnect", + "/api/daily/run", + "/api/weekly-recap/latest", + } + + assert route_paths.isdisjoint(excluded_paths) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_coach_registry_never_loads_external_training_providers(): + from api.services.ongoing_tools import build_ongoing_tool_registry + + async with build_ongoing_tool_registry( + object(), # type: ignore[arg-type] + user_id="owner-1", + ) as registry: + assert registry.registered_tool_names() == { + "get_athlete_profile", + "get_current_season_plan", + "get_current_weekly_plan", + "get_upcoming_competitions", + } + + +@pytest.mark.unit +def test_local_first_eval_cases_forbid_fabricated_external_evidence(): + suite = load_eval_suite(Path("tests/fixtures/head_coach_eval_cases.json")) + provider_free_cases = { + case.scenario_id: case.required_invariants + for case in suite.cases + if "no_fabricated_provider_evidence" in case.required_invariants + } + + assert provider_free_cases == { + "missed_training_week": [ + "schema_valid", + "athlete_report_used", + "no_fabricated_provider_evidence", + ], + "optional_evidence_unavailable": [ + "schema_valid", + "tool_gap_disclosed", + "no_fabricated_provider_evidence", + ], + "sparse_beginner_first_plan": [ + "schema_valid", + "declared_constraints_preserved", + "no_fabricated_provider_evidence", + ], + } + + +@pytest.mark.unit +def test_public_demo_does_not_advertise_removed_provider_or_recap_paths(): + demo_source = Path("web/app/src/app/demo/page.tsx").read_text() + + assert "optional provider" not in demo_source.lower() + assert "provider data" not in demo_source.lower() + assert "questions, recaps" not in demo_source.lower() diff --git a/tests/test_recap_cooldown.py b/tests/test_recap_cooldown.py index 518af41..07696a1 100644 --- a/tests/test_recap_cooldown.py +++ b/tests/test_recap_cooldown.py @@ -1,43 +1,13 @@ import uuid from datetime import UTC, datetime from types import SimpleNamespace -from typing import Any, cast from unittest.mock import MagicMock import pytest from fastapi import HTTPException -from api.models.credentials import WhoopCredentials -from api.models.integration_connection import IntegrationConnection from api.services import recap from api.services.full_run_policy import WeeklyRecapAvailability -from api.services.integration_status import build_integrations_status - - -def _healthy_integrations_status(): - return build_integrations_status( - settings=cast( - "Any", - SimpleNamespace( - whoop_oauth_client_id="client-id", - whoop_oauth_client_secret="client-secret", - ), - ), - crypto_service=None, - whoop=WhoopCredentials( - user_id=uuid.uuid4(), - encrypted_access_token=b"access-token", - encrypted_refresh_token=b"refresh-token", - expires_at=datetime(2026, 3, 8, tzinfo=UTC), - scope="offline read:recovery", - whoop_user_id=42, - ), - now=datetime(2026, 3, 7, tzinfo=UTC), - ) - - -async def _fake_load_integrations_status(*_args, **_kwargs): - return _healthy_integrations_status() class _RefreshTrackingDb: @@ -147,7 +117,6 @@ async def _fake_get_quota(*_args, **_kwargs): serializer = _RecapSerializerSpy(db) monkeypatch.setattr(recap, "get_local_usage_context", _fake_recap_usage_context) monkeypatch.setattr(recap, "evaluate_weekly_recap_availability", _eligible_recap_availability(anchor, window_start, window_end)) - monkeypatch.setattr(recap, "load_integrations_status", _fake_load_integrations_status) monkeypatch.setattr(recap, "_get_recap_run_for_anchor", _fake_no_recap_run_for_anchor) monkeypatch.setattr(recap, "_prepare_pending_recap_run", _fake_prepare_pending_recap_run) monkeypatch.setattr(recap, "_get_active_weekly_plan_for_recap", _fake_get_active_weekly_plan_for_recap) @@ -179,7 +148,6 @@ async def _fake_availability(*_args, **_kwargs): ) monkeypatch.setattr(recap, "evaluate_weekly_recap_availability", _fake_availability) - monkeypatch.setattr(recap, "load_integrations_status", _fake_load_integrations_status) async def _fake_usage_context(*_args, **_kwargs): return SimpleNamespace( has_access=True, @@ -220,7 +188,6 @@ async def _fake_availability(*_args, **_kwargs): ) monkeypatch.setattr(recap, "evaluate_weekly_recap_availability", _fake_availability) - monkeypatch.setattr(recap, "load_integrations_status", _fake_load_integrations_status) async def _fake_usage_context(*_args, **_kwargs): return SimpleNamespace( @@ -294,7 +261,7 @@ async def test_execute_recap_turn_refreshes_run_before_serializing_completed_rec @pytest.mark.asyncio -async def test_execute_recap_turn_blocks_when_training_provider_was_disconnected(monkeypatch): +async def test_execute_recap_turn_continues_when_training_provider_is_disconnected(monkeypatch): async def _fake_usage_context(*_args, **_kwargs): return SimpleNamespace( has_access=True, @@ -314,41 +281,20 @@ async def _fake_availability(*_args, **_kwargs): existing_run_id=None, ) - async def _fake_disconnected_integrations(*_args, **_kwargs): - return build_integrations_status( - settings=cast( - "Any", - SimpleNamespace( - whoop_oauth_client_id="client-id", - whoop_oauth_client_secret="client-secret", - ), - ), - crypto_service=None, - whoop=None, - whoop_history=IntegrationConnection( - user_id=uuid.uuid4(), - provider="whoop", - first_connected_at=datetime(2026, 3, 1, tzinfo=UTC), - last_connected_at=datetime(2026, 3, 4, tzinfo=UTC), - last_disconnected_at=datetime(2026, 3, 7, tzinfo=UTC), - last_disconnect_reason="user_initiated", - ), - now=datetime(2026, 3, 7, tzinfo=UTC), - ) + async def _provider_free_path_reached(*_args, **_kwargs): + raise RuntimeError("provider-free recap path reached") monkeypatch.setattr(recap, "get_local_usage_context", _fake_usage_context) monkeypatch.setattr(recap, "evaluate_weekly_recap_availability", _fake_availability) - monkeypatch.setattr(recap, "load_integrations_status", _fake_disconnected_integrations) + monkeypatch.setattr(recap, "_get_recap_run_for_anchor", _provider_free_path_reached) - with pytest.raises(HTTPException) as exc: + with pytest.raises(RuntimeError, match="provider-free recap path reached"): await recap.execute_recap_turn( object(), # type: ignore[arg-type] user_id=uuid.uuid4(), thread=MagicMock(), ) - assert exc.value.status_code == 400 - assert exc.value.detail == "WHOOP was disconnected. Reconnect it in Settings before starting a run." @pytest.mark.asyncio diff --git a/tests/test_release_audit_contract.py b/tests/test_release_audit_contract.py new file mode 100644 index 0000000..67f2c5a --- /dev/null +++ b/tests/test_release_audit_contract.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +RELEASE_AUDIT_SCRIPT = REPO_ROOT / "scripts/release_audit.sh" + + +def _run(command: list[str], *, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, check=check, capture_output=True, text=True) + + +def _initialize_repository(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + repository.mkdir() + _run(["git", "init", "-b", "main"], cwd=repository) + _run(["git", "config", "user.email", "release-audit@example.test"], cwd=repository) + _run(["git", "config", "user.name", "Release Audit Test"], cwd=repository) + (repository / ".gitignore").write_text(".env\n.tmp/\n", encoding="utf-8") + (repository / ".gitleaks.toml").write_text('title = "test"\n', encoding="utf-8") + (repository / "README.md").write_text("Synthetic release fixture.\n", encoding="utf-8") + (repository / "scripts").mkdir() + shutil.copy2(RELEASE_AUDIT_SCRIPT, repository / "scripts/release_audit.sh") + _run(["git", "add", "."], cwd=repository) + _run(["git", "commit", "-m", "initial"], cwd=repository) + return repository + + +def _write_fake_gitleaks(tmp_path: Path) -> Path: + fake_gitleaks = tmp_path / "fake-gitleaks" + fake_gitleaks.write_text( + """#!/usr/bin/env bash +set -euo pipefail +report_path="" +target="" +mode="" +log_opts="" +while [[ $# -gt 0 ]]; do + case "$1" in + --report-path) + report_path="$2" + shift 2 + ;; + --config|--report-format) + shift 2 + ;; + --log-opts) + log_opts="$2" + shift 2 + ;; + --redact=*) + shift + ;; + git|dir) + mode="$1" + shift + ;; + *) + target="$1" + shift + ;; + esac +done +mkdir -p "$(dirname "$report_path")" +printf '[]\n' > "$report_path" +if [[ "$mode" == "git" && "${REQUIRE_FULL_HISTORY:-}" == "1" && "$log_opts" != "--all --full-history" ]]; then + exit 8 +fi +if [[ -f "${FAKE_GITLEAKS_FAIL_MARKER:-}" ]]; then + printf '[{"RuleID":"synthetic","Secret":"%s"}]\n' "${FAKE_SECRET_VALUE:-hidden}" > "$report_path" + exit 1 +fi +if [[ -n "${FORBIDDEN_CONTENT:-}" ]] && grep -R -F -q -- "$FORBIDDEN_CONTENT" "$target" 2>/dev/null; then + exit 9 +fi +if [[ "$mode" == "git" && -n "${HISTORY_SECRET_MARKER:-}" ]]; then + history="$(git --git-dir="$target" log -p --all)" + if grep -F -q -- "$HISTORY_SECRET_MARKER" <<< "$history"; then + printf '[{"RuleID":"synthetic-history","Secret":"redacted"}]\n' > "$report_path" + exit 1 + fi +fi +""", + encoding="utf-8", + ) + fake_gitleaks.chmod(0o755) + return fake_gitleaks + + +def _audit(repository: Path, fake_gitleaks: Path, **extra_env: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update(extra_env) + env["RELEASE_AUDIT_GITLEAKS_BIN"] = str(fake_gitleaks) + return subprocess.run( + [str(repository / "scripts/release_audit.sh")], + cwd=repository, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def test_release_audit_ignores_local_secret_contents_and_preserves_repository(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + secret_value = "local-secret-must-never-be-scanned-or-printed" + (repository / ".env").write_text(f"OPENAI_API_KEY={secret_value}\n", encoding="utf-8") + before_head = _run(["git", "rev-parse", "HEAD"], cwd=repository).stdout + before_refs = _run(["git", "show-ref"], cwd=repository).stdout + + result = _audit(repository, fake_gitleaks, FORBIDDEN_CONTENT=secret_value) + + assert result.returncode == 0, result.stdout + result.stderr + assert secret_value not in result.stdout + assert secret_value not in result.stderr + assert "local secret/config paths present (contents not inspected): .env" in result.stdout + assert _run(["git", "rev-parse", "HEAD"], cwd=repository).stdout == before_head + assert _run(["git", "show-ref"], cwd=repository).stdout == before_refs + assert _run(["git", "status", "--short"], cwd=repository).stdout == "" + + +def test_release_audit_redacts_scanner_failure_output(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + fail_marker = tmp_path / "fail" + fail_marker.touch() + secret_value = "synthetic-secret-that-must-not-reach-output" + + result = _audit( + repository, + fake_gitleaks, + FAKE_GITLEAKS_FAIL_MARKER=str(fail_marker), + FAKE_SECRET_VALUE=secret_value, + ) + + assert result.returncode != 0 + assert secret_value not in result.stdout + assert secret_value not in result.stderr + assert "secret scan failed" in result.stdout + reports = list((repository / ".tmp" / "release-audit").glob("*.json")) + assert reports + assert any(secret_value in report.read_text(encoding="utf-8") for report in reports) + + +def test_release_audit_scans_secret_reachable_only_from_another_branch(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + secret_marker = "historical-secret-on-release-branch" + _run(["git", "switch", "-c", "historical-secret"], cwd=repository) + (repository / "legacy.txt").write_text(f"token={secret_marker}\n", encoding="utf-8") + _run(["git", "add", "legacy.txt"], cwd=repository) + _run(["git", "commit", "-m", "add historical fixture"], cwd=repository) + _run(["git", "switch", "main"], cwd=repository) + + result = _audit( + repository, + fake_gitleaks, + HISTORY_SECRET_MARKER=secret_marker, + REQUIRE_FULL_HISTORY="1", + ) + + assert result.returncode != 0 + assert secret_marker not in result.stdout + assert secret_marker not in result.stderr + assert "history secret scan failed" in result.stdout + + +def test_release_audit_scans_remote_only_branch_and_skips_symbolic_remote_head(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + remote = tmp_path / "remote.git" + _run(["git", "init", "--bare", str(remote)], cwd=tmp_path) + _run(["git", "remote", "add", "origin", str(remote)], cwd=repository) + _run(["git", "push", "-u", "origin", "main"], cwd=repository) + + secret_marker = "historical-secret-on-remote-only-branch" + _run(["git", "switch", "-c", "published-secret"], cwd=repository) + (repository / "remote-history.txt").write_text(f"token={secret_marker}\n", encoding="utf-8") + _run(["git", "add", "remote-history.txt"], cwd=repository) + _run(["git", "commit", "-m", "add remote-only historical fixture"], cwd=repository) + _run(["git", "push", "origin", "published-secret"], cwd=repository) + _run(["git", "switch", "main"], cwd=repository) + _run(["git", "branch", "-D", "published-secret"], cwd=repository) + _run(["git", "remote", "set-head", "origin", "main"], cwd=repository) + + result = _audit(repository, fake_gitleaks, HISTORY_SECRET_MARKER=secret_marker) + + assert result.returncode != 0 + assert secret_marker not in result.stdout + assert secret_marker not in result.stderr + assert "history secret scan failed" in result.stdout + scanned_refs = (repository / ".tmp" / "release-audit" / "scanned-refs.txt").read_text( + encoding="utf-8" + ) + assert "refs/remotes/origin/published-secret" in scanned_refs + assert "refs/remotes/origin/HEAD" not in scanned_refs + + +def test_release_audit_fails_when_remote_branch_was_never_fetched(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + remote = tmp_path / "remote.git" + publisher = tmp_path / "publisher" + _run(["git", "init", "--bare", str(remote)], cwd=tmp_path) + _run(["git", "remote", "add", "origin", str(remote)], cwd=repository) + _run(["git", "push", "-u", "origin", "main"], cwd=repository) + _run(["git", "clone", str(remote), str(publisher)], cwd=tmp_path) + _run(["git", "config", "user.email", "publisher@example.test"], cwd=publisher) + _run(["git", "config", "user.name", "Publisher"], cwd=publisher) + _run(["git", "switch", "-c", "unfetched-branch"], cwd=publisher) + (publisher / "remote-only.txt").write_text("remote-only\n", encoding="utf-8") + _run(["git", "add", "remote-only.txt"], cwd=publisher) + _run(["git", "commit", "-m", "remote only"], cwd=publisher) + _run(["git", "push", "origin", "unfetched-branch"], cwd=publisher) + + result = _audit(repository, fake_gitleaks) + + assert result.returncode != 0 + assert "missing or stale" in result.stdout + + +@pytest.mark.parametrize( + "tracked_path", + [ + ".env.production", + "data/storage/athlete.json", + "backup/database.dump", + "logs/coach.log", + ], +) +def test_release_audit_rejects_tracked_private_or_generated_paths(tmp_path: Path, tracked_path: str): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + path = repository / tracked_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("synthetic fixture\n", encoding="utf-8") + _run(["git", "add", "-f", tracked_path], cwd=repository) + _run(["git", "commit", "-m", "add forbidden artifact"], cwd=repository) + + result = _audit(repository, fake_gitleaks) + + assert result.returncode != 0 + assert tracked_path in result.stdout + assert "forbidden tracked release path" in result.stdout + + +def test_release_audit_rejects_dirty_release_candidate(tmp_path: Path): + repository = _initialize_repository(tmp_path) + fake_gitleaks = _write_fake_gitleaks(tmp_path) + (repository / "README.md").write_text("dirty\n", encoding="utf-8") + + result = _audit(repository, fake_gitleaks) + + assert result.returncode != 0 + assert "working tree is not clean" in result.stdout + assert "README.md" in result.stdout diff --git a/tests/test_release_migration_contract.py b/tests/test_release_migration_contract.py new file mode 100644 index 0000000..8b635d6 --- /dev/null +++ b/tests/test_release_migration_contract.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +from uuid import uuid4 + +import psycopg +import pytest +from psycopg import sql +from psycopg.types.json import Jsonb + +REPO_ROOT = Path(__file__).resolve().parent.parent +BASELINE_REVISION = "001_initial_local_first" +HEAD_REVISION = "002_head_coach_checkpoints" + + +def _psycopg_url(database_url: str) -> str: + return database_url.replace("postgresql+asyncpg://", "postgresql://", 1) + + +def _database_url(admin_url: str, database_name: str) -> str: + parsed = urlsplit(_psycopg_url(admin_url)) + return urlunsplit((parsed.scheme, parsed.netloc, f"/{database_name}", parsed.query, "")) + + +def _upgrade(database_url: str, revision: str): + env = os.environ.copy() + env["DATABASE_URL"] = database_url + result = subprocess.run( + [sys.executable, "-m", "alembic", "-c", "alembic.ini", "upgrade", revision], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.integration +def test_disposable_001_to_002_upgrade_preserves_existing_owner_and_active_plans(): + """Exercise the additive upgrade only against an explicitly authorized disposable database.""" + admin_url = os.getenv("HEAD_COACH_MIGRATION_TEST_ADMIN_URL") + if not admin_url: + pytest.skip("HEAD_COACH_MIGRATION_TEST_ADMIN_URL is not configured") + + database_name = f"paced_coach_migration_test_{uuid4().hex}" + database_url = _database_url(admin_url, database_name) + owner_id = uuid4() + job_id = uuid4() + season_plan_id = uuid4() + weekly_plan_id = uuid4() + season_payload = {"schema_version": 1, "title": "Synthetic historical season"} + weekly_payload = {"schema_version": 1, "title": "Synthetic historical week"} + + with psycopg.connect(_psycopg_url(admin_url), autocommit=True) as admin_connection: + admin_connection.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name))) + + try: + _upgrade(database_url, BASELINE_REVISION) + with psycopg.connect(database_url) as connection: + connection.execute( + """ + INSERT INTO users (id, local_owner_key, email, credits) + VALUES (%s, %s, %s, %s) + """, + (owner_id, f"migration-test-{owner_id}", f"{owner_id}@example.test", 0), + ) + connection.execute( + """ + INSERT INTO analysis_jobs (id, user_id, status, config) + VALUES (%s, %s, %s, %s) + """, + (job_id, owner_id, "completed", Jsonb({"synthetic": True})), + ) + connection.execute( + """ + INSERT INTO active_season_plans (id, user_id, version, plan_data, source_job_id) + VALUES (%s, %s, %s, %s, %s) + """, + (season_plan_id, owner_id, 1, Jsonb(season_payload), job_id), + ) + connection.execute( + """ + INSERT INTO active_weekly_plans (id, user_id, version, plan_data, source_job_id) + VALUES (%s, %s, %s, %s, %s) + """, + (weekly_plan_id, owner_id, 1, Jsonb(weekly_payload), job_id), + ) + connection.commit() + + _upgrade(database_url, "head") + + with psycopg.connect(database_url) as connection: + owner_row = connection.execute( + "SELECT id, local_owner_key FROM users WHERE id = %s", + (owner_id,), + ).fetchone() + season_row = connection.execute( + "SELECT id, user_id, version, plan_data, source_job_id FROM active_season_plans WHERE id = %s", + (season_plan_id,), + ).fetchone() + weekly_row = connection.execute( + "SELECT id, user_id, version, plan_data, source_job_id FROM active_weekly_plans WHERE id = %s", + (weekly_plan_id,), + ).fetchone() + revision = connection.execute("SELECT version_num FROM alembic_version").fetchone() + checkpoint_tables = { + row[0] + for row in connection.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name LIKE 'checkpoint%' + """ + ).fetchall() + } + + assert owner_row == (owner_id, f"migration-test-{owner_id}") + assert season_row == (season_plan_id, owner_id, 1, season_payload, job_id) + assert weekly_row == (weekly_plan_id, owner_id, 1, weekly_payload, job_id) + assert revision == (HEAD_REVISION,) + assert checkpoint_tables == { + "checkpoint_migrations", + "checkpoints", + "checkpoint_blobs", + "checkpoint_writes", + } + finally: + with psycopg.connect(_psycopg_url(admin_url), autocommit=True) as admin_connection: + admin_connection.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s", + (database_name,), + ) + admin_connection.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database_name))) diff --git a/tests/test_retry_handler.py b/tests/test_retry_handler.py index dfd5c05..e2aa113 100644 --- a/tests/test_retry_handler.py +++ b/tests/test_retry_handler.py @@ -1,9 +1,8 @@ -"""Tests for retry_handler with OpenAI/Anthropic rate-limit resilience.""" +"""Tests for retry_handler with OpenAI rate-limit resilience.""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch -import anthropic import openai import pytest @@ -31,34 +30,6 @@ def _make_openai_rate_limit_error(retry_after: str | None = None): ) -def _make_anthropic_rate_limit_error(): - mock_response = MagicMock() - mock_response.status_code = 429 - mock_response.headers = {} - return anthropic.RateLimitError( - message="Rate limit exceeded", - response=mock_response, - body=None, - ) - - -def _make_anthropic_internal_server_error(): - mock_response = MagicMock() - mock_response.status_code = 503 - mock_response.headers = {} - return anthropic.InternalServerError( - message="Grammar compilation is temporarily unavailable. Please try again.", - response=mock_response, - body={ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Grammar compilation is temporarily unavailable. Please try again.", - }, - }, - ) - - FAST_CONFIG = RetryConfig(max_retries=3, base_delay=0.01, max_delay=0.05, jitter=False) @@ -124,36 +95,6 @@ async def flaky(): assert result == "ok" assert call_count == 2 - @pytest.mark.asyncio - async def test_anthropic_rate_limit_is_retried(self): - call_count = 0 - - async def flaky(): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise _make_anthropic_rate_limit_error() - return "ok" - - result = await retry_with_backoff(flaky, FAST_CONFIG, "test") - assert result == "ok" - assert call_count == 2 - - @pytest.mark.asyncio - async def test_anthropic_internal_server_error_is_retried(self): - call_count = 0 - - async def flaky(): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise _make_anthropic_internal_server_error() - return "ok" - - result = await retry_with_backoff(flaky, FAST_CONFIG, "test") - assert result == "ok" - assert call_count == 2 - @pytest.mark.asyncio async def test_non_retryable_exception_breaks_immediately(self): call_count = 0 diff --git a/tests/test_season_planner_node.py b/tests/test_season_planner_node.py deleted file mode 100644 index be2e896..0000000 --- a/tests/test_season_planner_node.py +++ /dev/null @@ -1,146 +0,0 @@ -from typing import Any, cast -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from services.ai.langgraph.nodes.season_planner_node import season_planner_node -from services.ai.langgraph.schemas.agent_outputs import SeasonPlannerDecision -from services.ai.langgraph.state.training_analysis_state import TrainingAnalysisState, create_initial_state - - -async def _passthrough(func, *_args): - return await func() - - -def _expert_outputs() -> dict[str, Any]: - payload = { - "signals": ["Load is stable enough for a strategic season update."], - "evidence": ["Recent training context was included."], - "implications": ["Keep the next phase continuous with the existing plan."], - } - return {"output": {"for_season_planner": payload}} - - -def _state( - *, - season_plan: str | None = "# Existing Season Plan", - planning_context: str = "Custom planning instructions for this run:\nAdd one playful hill challenge each week.", -) -> TrainingAnalysisState: - state = create_initial_state( - user_id="test_user", - athlete_name="Test Athlete", - training_data={"generated_at_utc": "2026-05-03T00:00:00+00:00", "sources": {"strava": {}}}, - planning_context=planning_context, - current_date={"date": "2026-05-03"}, - competitions=[], - execution_id="test_exec_season", - hitl_enabled=False, - season_plan=season_plan, - ) - state["metrics_outputs"] = cast("Any", _expert_outputs()) - state["activity_outputs"] = cast("Any", _expert_outputs()) - state["physiology_outputs"] = cast("Any", _expert_outputs()) - return state - - -def test_season_planner_decision_schema_stays_small(): - assert list(SeasonPlannerDecision.model_fields) == ["action", "rationale"] - - -@pytest.mark.asyncio -async def test_season_planner_generates_markdown_after_update_decision(): - mock_llm = Mock() - mock_structured = Mock() - mock_structured.ainvoke = AsyncMock( - return_value=SeasonPlannerDecision(action="update", rationale="The event calendar changed.") - ) - mock_llm.with_structured_output.return_value = mock_structured - mock_llm.ainvoke = AsyncMock(return_value="# Updated Season Plan\n\n## Phase 1") - - with ( - patch("services.ai.langgraph.nodes.season_planner_node.ModelSelector.get_llm", return_value=mock_llm), - patch("services.ai.langgraph.nodes.season_planner_node.configure_node_tools", return_value=[]), - patch( - "services.ai.langgraph.nodes.season_planner_node.retry_with_backoff", - new=AsyncMock(side_effect=_passthrough), - ), - ): - result = await season_planner_node(_state()) - - assert result["season_plan"] == "# Updated Season Plan\n\n## Phase 1" - assert result["season_plan_action"] == "update" - assert result["season_plan_reused"] is False - assert result["season_plan_needs_formatting"] is True - mock_llm.with_structured_output.assert_called_once_with(SeasonPlannerDecision, method="json_schema") - mock_structured.ainvoke.assert_awaited_once() - mock_llm.ainvoke.assert_awaited_once() - - decision_messages = mock_structured.ainvoke.await_args.args[0] - decision_user_prompt = decision_messages[1]["content"] - assert "Planning Context and Custom Instructions" in decision_user_prompt - assert "Add one playful hill challenge each week." in decision_user_prompt - assert "valid reason to choose" in decision_user_prompt - assert "lacks a competition-aware creative challenge thread" in decision_user_prompt - - update_messages = mock_llm.ainvoke.await_args.args[0] - update_user_prompt = update_messages[1]["content"] - assert "Planning Context and Custom Instructions" in update_user_prompt - assert "Add one playful hill challenge each week." in update_user_prompt - assert "Preserve explicit custom planning instructions" in update_user_prompt - assert "season-long creative challenge thread" in update_user_prompt - assert "larger signature/breakthrough challenges" in update_user_prompt - assert "Do not hardcode stock challenges" in update_user_prompt - - -@pytest.mark.asyncio -async def test_season_planner_reuses_existing_plan_without_markdown_generation(): - mock_llm = Mock() - mock_structured = Mock() - mock_structured.ainvoke = AsyncMock( - return_value=SeasonPlannerDecision(action="reuse", rationale="The existing plan still fits.") - ) - mock_llm.with_structured_output.return_value = mock_structured - mock_llm.ainvoke = AsyncMock(return_value="# Should not be generated") - - with ( - patch("services.ai.langgraph.nodes.season_planner_node.ModelSelector.get_llm", return_value=mock_llm), - patch("services.ai.langgraph.nodes.season_planner_node.configure_node_tools", return_value=[]), - patch( - "services.ai.langgraph.nodes.season_planner_node.retry_with_backoff", - new=AsyncMock(side_effect=_passthrough), - ), - ): - result = await season_planner_node(_state()) - - assert result["season_plan_action"] == "reuse" - assert result["season_plan_reused"] is True - assert result["season_plan_needs_formatting"] is False - assert "season_plan" not in result - mock_structured.ainvoke.assert_awaited_once() - mock_llm.ainvoke.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_season_planner_skips_decision_when_no_existing_plan(): - mock_llm = Mock() - mock_structured = Mock() - mock_structured.ainvoke = AsyncMock( - return_value=SeasonPlannerDecision(action="reuse", rationale="This should not be used.") - ) - mock_llm.with_structured_output.return_value = mock_structured - mock_llm.ainvoke = AsyncMock(return_value="# New Season Plan") - - with ( - patch("services.ai.langgraph.nodes.season_planner_node.ModelSelector.get_llm", return_value=mock_llm), - patch("services.ai.langgraph.nodes.season_planner_node.configure_node_tools", return_value=[]), - patch( - "services.ai.langgraph.nodes.season_planner_node.retry_with_backoff", - new=AsyncMock(side_effect=_passthrough), - ), - ): - result = await season_planner_node(_state(season_plan=None)) - - assert result["season_plan"] == "# New Season Plan" - assert result["season_plan_action"] == "update" - mock_structured.ainvoke.assert_not_awaited() - mock_llm.ainvoke.assert_awaited_once() diff --git a/tests/test_status_messages.py b/tests/test_status_messages.py index 595b5f6..e0e1613 100644 --- a/tests/test_status_messages.py +++ b/tests/test_status_messages.py @@ -2,92 +2,52 @@ from api.services.status_messages import ( current_analysis_step, - mark_analysis_progress_step_completed, + initial_head_coach_progress_steps, mark_analysis_progress_step_started, normalize_analysis_progress_steps, - record_analysis_step_timing, ) -def test_record_analysis_step_timing_adds_actual_timing_fields(): - completed_at = datetime(2026, 3, 5, 21, 0, 0, tzinfo=UTC) +def test_head_coach_progress_uses_only_product_lifecycle_nodes() -> None: + steps = initial_head_coach_progress_steps() - updated_steps = record_analysis_step_timing( - None, - node_name="analysis_formatter", - duration_seconds=42.25, - timestamp=completed_at, - ) + assert [step["node"] for step in steps] == [ + "head_coach_understanding_context", + "head_coach_designing_strategy", + "head_coach_reviewing_constraints", + "head_coach_awaiting_input", + "head_coach_building_execution_block", + "head_coach_saving_plan", + ] + assert all(step["status"] == "pending" for step in steps) - step = next(step for step in updated_steps if step["node"] == "analysis_formatter") - assert step["actual_completed_at"] == completed_at.isoformat() - assert step["actual_started_at"] == datetime(2026, 3, 5, 20, 59, 17, 750000, tzinfo=UTC).isoformat() - assert step["duration_seconds"] == 42.25 - -def test_normalize_analysis_progress_steps_preserves_actual_timing_fields(): +def test_normalize_progress_preserves_head_coach_timestamps() -> None: normalized = normalize_analysis_progress_steps( [ { - "node": "weekly_planner", - "label": "Building your weekly plan...", + "node": "head_coach_designing_strategy", "status": "completed", - "actual_started_at": "2026-03-05T20:00:00+00:00", - "actual_completed_at": "2026-03-05T20:01:30+00:00", + "actual_started_at": "2026-07-19T20:00:00+00:00", + "actual_completed_at": "2026-07-19T20:01:30+00:00", "duration_seconds": 90.0, } ] ) - step = next(step for step in normalized if step["node"] == "weekly_planner") - assert step["actual_started_at"] == "2026-03-05T20:00:00+00:00" - assert step["actual_completed_at"] == "2026-03-05T20:01:30+00:00" + step = next(step for step in normalized if step["node"] == "head_coach_designing_strategy") + assert step["actual_started_at"] == "2026-07-19T20:00:00+00:00" + assert step["actual_completed_at"] == "2026-07-19T20:01:30+00:00" assert step["duration_seconds"] == 90.0 -def test_mark_analysis_progress_steps_supports_multiple_active_nodes(): - started_at = datetime(2026, 3, 5, 21, 0, 0, tzinfo=UTC) - progress_steps, _ = mark_analysis_progress_step_started( +def test_started_progress_exposes_product_copy() -> None: + started_at = datetime(2026, 7, 19, 21, 0, 0, tzinfo=UTC) + progress_steps, label = mark_analysis_progress_step_started( None, - node_name="metrics_summarizer", - timestamp=started_at, - ) - progress_steps, _ = mark_analysis_progress_step_started( - progress_steps, - node_name="physiology_summarizer", + node_name="head_coach_understanding_context", timestamp=started_at, ) - metrics_step = next(step for step in progress_steps if step["node"] == "metrics_summarizer") - physiology_step = next(step for step in progress_steps if step["node"] == "physiology_summarizer") - - assert metrics_step["status"] == "active" - assert physiology_step["status"] == "active" - assert current_analysis_step(progress_steps) == "Summarizing training metrics... (+1 more)" - - -def test_mark_analysis_progress_step_completed_only_completes_target_node(): - timestamp = datetime(2026, 3, 5, 21, 0, 0, tzinfo=UTC) - progress_steps, _ = mark_analysis_progress_step_started( - None, - node_name="metrics_summarizer", - timestamp=timestamp, - ) - progress_steps, _ = mark_analysis_progress_step_started( - progress_steps, - node_name="physiology_summarizer", - timestamp=timestamp, - ) - - updated_steps = mark_analysis_progress_step_completed( - progress_steps, - node_name="metrics_summarizer", - timestamp=timestamp, - ) - - metrics_step = next(step for step in updated_steps if step["node"] == "metrics_summarizer") - physiology_step = next(step for step in updated_steps if step["node"] == "physiology_summarizer") - - assert metrics_step["status"] == "completed" - assert metrics_step["completed_at"] == timestamp.isoformat() - assert physiology_step["status"] == "active" + assert label == "Understanding your goals and constraints..." + assert current_analysis_step(progress_steps) == label diff --git a/tests/test_tool_calling_helper.py b/tests/test_tool_calling_helper.py deleted file mode 100644 index 38da3b7..0000000 --- a/tests/test_tool_calling_helper.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest - -from services.ai.langgraph.nodes import tool_calling_helper -from services.ai.langgraph.nodes.tool_calling_helper import handle_tool_calling_in_node - - -class _FakeResponse: - def __init__(self, *, tool_calls=None, content: str = ""): - self.tool_calls = tool_calls or [] - self.content = content - - -class _FakeLlmWithTools: - def __init__(self, responses: list[_FakeResponse]): - self._responses = iter(responses) - self.calls = 0 - - async def ainvoke(self, _conversation, config=None): - _ = config - self.calls += 1 - return next(self._responses) - - -class _FakeFinalLlm: - def __init__(self, payload: dict): - self._payload = payload - self.calls = 0 - - async def ainvoke(self, _conversation, config=None): - _ = config - self.calls += 1 - return self._payload - - -class _FakeTool: - def __init__(self, *, name: str, result: dict): - self.name = name - self._result = result - - async def ainvoke(self, _args): - return self._result - - -@pytest.mark.asyncio -async def test_handle_tool_calling_emits_status_and_traces_for_successful_tool_call(): - llm = _FakeLlmWithTools( - responses=[ - _FakeResponse(tool_calls=[{"name": "get_training_snapshot", "args": {"window": 7}, "id": "tool-1"}]), - _FakeResponse(content="final"), - ] - ) - final_llm = _FakeFinalLlm({"assistant_message": "done"}) - tool = _FakeTool(name="get_training_snapshot", result={"sessions_7d": 5}) - statuses: list[dict] = [] - traces: list[dict] = [] - - result = await handle_tool_calling_in_node( - llm_with_tools=llm, - messages=[{"role": "system", "content": "You are a coach"}, {"role": "user", "content": "How was my week?"}], - tools=[tool], - max_iterations=3, - final_output_llm=final_llm, - status_emitter=statuses.append, - tool_trace_collector=traces.append, - ) - - assert result == {"assistant_message": "done"} - assert any(status["step"] == "thinking" for status in statuses) - assert any(status["step"] == "tool_call_start" for status in statuses) - assert any(status["step"] == "tool_call_end" for status in statuses) - assert len(traces) == 1 - assert traces[0]["tool_name"] == "get_training_snapshot" - assert traces[0]["truncated"] is False - assert traces[0]["char_len"] > 0 - - -@pytest.mark.asyncio -async def test_handle_tool_calling_continues_when_requested_tool_is_missing(): - llm = _FakeLlmWithTools( - responses=[ - _FakeResponse(tool_calls=[{"name": "missing_tool", "args": {"x": 1}, "id": "tool-1"}]), - _FakeResponse(content="final"), - ] - ) - final_llm = _FakeFinalLlm({"assistant_message": "fallback complete"}) - statuses: list[dict] = [] - traces: list[dict] = [] - - result = await handle_tool_calling_in_node( - llm_with_tools=llm, - messages=[{"role": "system", "content": "You are a coach"}, {"role": "user", "content": "Help"}], - tools=[_FakeTool(name="other_tool", result={"ok": True})], - max_iterations=3, - final_output_llm=final_llm, - status_emitter=statuses.append, - tool_trace_collector=traces.append, - ) - - assert result == {"assistant_message": "fallback complete"} - assert len(traces) == 1 - assert traces[0]["tool_name"] == "missing_tool" - assert "not found" in traces[0]["result_preview"].lower() - assert any(status["step"] == "tool_call_end" for status in statuses) - - -@pytest.mark.asyncio -async def test_handle_tool_calling_uses_structured_llm_directly_when_no_tools(): - llm = _FakeLlmWithTools(responses=[_FakeResponse(content="unstructured")]) - final_llm = _FakeFinalLlm({"assistant_message": "structured"}) - statuses: list[dict] = [] - - result = await handle_tool_calling_in_node( - llm_with_tools=llm, - messages=[{"role": "system", "content": "You are a coach"}, {"role": "user", "content": "Help"}], - tools=[], - max_iterations=3, - final_output_llm=final_llm, - status_emitter=statuses.append, - ) - - assert result == {"assistant_message": "structured"} - assert llm.calls == 0 - assert final_llm.calls == 1 - assert statuses == [ - { - "step": "thinking", - "message": "Preparing your final coaching response...", - "iteration": 1, - } - ] - - -@pytest.mark.asyncio -async def test_handle_tool_calling_respects_shared_tool_semaphore(monkeypatch): - llm = _FakeLlmWithTools( - responses=[ - _FakeResponse( - tool_calls=[ - {"name": "tool_a", "args": {}, "id": "tool-1"}, - {"name": "tool_b", "args": {}, "id": "tool-2"}, - ] - ), - _FakeResponse(content="final"), - ] - ) - final_llm = _FakeFinalLlm({"assistant_message": "done"}) - - max_active = 0 - active = 0 - - class _TrackingTool: - def __init__(self, name: str): - self.name = name - - async def ainvoke(self, _args): - nonlocal active, max_active - active += 1 - max_active = max(max_active, active) - await asyncio.sleep(0.01) - active -= 1 - return {"ok": self.name} - - shared_semaphore = asyncio.Semaphore(1) - monkeypatch.setattr(tool_calling_helper, "get_tool_semaphore", lambda: shared_semaphore) - - result = await handle_tool_calling_in_node( - llm_with_tools=llm, - messages=[{"role": "system", "content": "You are a coach"}, {"role": "user", "content": "Help"}], - tools=[_TrackingTool("tool_a"), _TrackingTool("tool_b")], - max_iterations=3, - final_output_llm=final_llm, - ) - - assert result == {"assistant_message": "done"} - assert max_active == 1 diff --git a/tests/test_training_data_projection.py b/tests/test_training_data_projection.py deleted file mode 100644 index c4fbd37..0000000 --- a/tests/test_training_data_projection.py +++ /dev/null @@ -1,184 +0,0 @@ -import json -from typing import cast - -from services.ai.langgraph.nodes.activity_summarizer_node import extract_activity_data -from services.ai.langgraph.nodes.metrics_summarizer_node import extract_metrics_data -from services.ai.langgraph.nodes.physiology_summarizer_node import extract_physiology_data -from services.ai.langgraph.nodes.training_data_projection import ( - build_training_transition_context, - compact_training_data_for_state, -) -from services.ai.langgraph.state.training_analysis_state import TrainingAnalysisState - - -def _sample_state() -> TrainingAnalysisState: - return cast( - "TrainingAnalysisState", - { - "training_data": { - "generated_at_utc": "2026-05-11T21:14:07Z", - "source_gaps": [], - "evidence_profile": {"connected_mode": "strava_whoop"}, - "sources": { - "strava": { - "athlete_profile": {"id": 123, "weight": 75.0}, - "activity_summary": {"count": 1}, - "training_load_history": [{"date": "2026-05-11", "load_value": 12.3}], - "recent_activities": [ - { - "id": 18461012431, - "resource_state": 2, - "name": "Morning Run", - "sport_type": "Run", - "start_date": "2026-05-11T08:02:15Z", - "distance": 8128.2, - "moving_time": 2979, - "elapsed_time": 2979, - "total_elevation_gain": 51.0, - "average_watts": 309.8, - "average_heartrate": 146.4, - "map": {"summary_polyline": "encoded-polyline"}, - "upload_id": 19566202235, - "external_id": "device_upload_123", - "kudos_count": 3, - }, - ], - }, - "whoop": { - "profile_basic": {"user_id": 999, "email": "athlete@example.test"}, - "body_measurement": {"height_meter": 1.8, "weight_kilogram": 75.0}, - "workouts": [ - { - "id": "workout-1", - "user_id": 999, - "created_at": "2026-05-11T09:16:29.589Z", - "updated_at": "2026-05-11T09:17:52.574Z", - "start": "2026-05-11T08:02:15.000Z", - "end": "2026-05-11T08:51:54.056Z", - "sport_name": "running", - "score": { - "strain": 13.9, - "average_heart_rate": 145, - "max_heart_rate": 181, - "zone_durations": {"zone_two_milli": 668980}, - }, - }, - ], - "cycles": [ - { - "id": 1490275548, - "user_id": 999, - "start": "2026-05-10T21:13:52.820Z", - "end": None, - "score": {"strain": 14.7, "average_heart_rate": 58}, - }, - ], - "sleeps": [ - { - "id": "sleep-1", - "cycle_id": 1490275548, - "user_id": 999, - "start": "2026-05-10T21:13:52.820Z", - "end": "2026-05-11T06:44:20.980Z", - "score": { - "sleep_performance_percentage": 82.0, - "stage_summary": {"total_in_bed_time_milli": 34228160}, - }, - } - ], - "recoveries": [ - { - "cycle_id": 1490275548, - "sleep_id": "sleep-1", - "user_id": 999, - "created_at": "2026-05-11T06:56:22.431Z", - "score": { - "recovery_score": 87.0, - "resting_heart_rate": 39.0, - "hrv_rmssd_milli": 167.7, - }, - } - ], - }, - }, - } - }, - ) - - -def test_activity_projection_merges_provider_sessions_and_drops_vendor_metadata(): - payload = extract_activity_data(_sample_state()) - - assert "activities" in payload - assert len(payload["activities"]) == 1 - assert payload["activities"][0]["sources"] == ["strava", "whoop"] - assert payload["activities"][0]["strava"]["distance_m"] == 8128.2 - assert payload["activities"][0]["whoop"]["strain"] == 13.9 - - serialized = json.dumps(payload) - assert "summary_polyline" not in serialized - assert "upload_id" not in serialized - assert "external_id" not in serialized - assert "user_id" not in serialized - assert "created_at" not in serialized - - -def test_metrics_projection_excludes_activity_detail_domain(): - payload = extract_metrics_data(_sample_state()) - - assert payload["strava"]["training_load_history"] == [{"date": "2026-05-11", "load_value": 12.3}] - assert "recent_activities" not in payload["strava"] - assert payload["whoop"]["cycles"] == [ - { - "start": "2026-05-10T21:13:52.820Z", - "strain": 14.7, - "average_heart_rate": 58, - } - ] - - -def test_physiology_projection_uses_measured_biometrics_without_load_series(): - payload = extract_physiology_data(_sample_state()) - - assert "proxy_activity_signals" not in payload - assert "cycles" not in payload["whoop"] - assert payload["whoop"]["recoveries"] == [ - { - "cycle_start": "2026-05-10T21:13:52.820Z", - "sleep_start": "2026-05-10T21:13:52.820Z", - "sleep_end": "2026-05-11T06:44:20.980Z", - "recovery_score": 87.0, - "resting_heart_rate": 39.0, - "hrv_rmssd_milli": 167.7, - } - ] - - -def test_compact_training_data_for_state_keeps_only_context_metadata(): - compacted = compact_training_data_for_state(_sample_state()["training_data"]) - - assert compacted == { - "generated_at_utc": "2026-05-11T21:14:07Z", - "sources_present": ["strava", "whoop"], - "evidence_profile": {"connected_mode": "strava_whoop"}, - } - - -def test_transition_context_preserves_recent_load_sessions_recovery_and_existing_plan(): - context = build_training_transition_context( - _sample_state()["training_data"], - current_date={"date": "2026-05-12", "day_name": "Tuesday"}, - existing_weekly_plan="- 2026-05-10 | Easy Run | intensity=low\n- 2026-05-11 | Rest | intensity=rest", - ) - - assert "## Transition Context" in context - assert "Recent Executed Sessions" in context - assert "Morning Run" in context - assert "8.1 km" in context - assert "Recent Training Load" in context - assert "load 12.3" in context - assert "Recent WHOOP Recovery" in context - assert "recovery 87" in context - assert "Existing Active Weekly Plan" in context - assert "intensity=low" in context - assert "first 3-7 days" in context diff --git a/tests/test_training_plan_prompt_contracts.py b/tests/test_training_plan_prompt_contracts.py deleted file mode 100644 index 6e0f12f..0000000 --- a/tests/test_training_plan_prompt_contracts.py +++ /dev/null @@ -1,191 +0,0 @@ -import inspect - -from services.ai.coach.continuum_turn_agent import _TURN_SYSTEM_PROMPT -from services.ai.coach.plan_modifier_agent import SYSTEM_PROMPT as PLAN_MODIFIER_SYSTEM_PROMPT -from services.ai.daily.daily_update_agent import DAILY_SYSTEM_PROMPT -from services.ai.langgraph.nodes.activity_expert_node import ACTIVITY_EXPERT_USER_PROMPT -from services.ai.langgraph.nodes.metrics_expert_node import METRICS_SYSTEM_PROMPT_BASE, METRICS_USER_PROMPT -from services.ai.langgraph.nodes.physiology_expert_node import PHYSIOLOGY_SYSTEM_PROMPT_BASE, PHYSIOLOGY_USER_PROMPT -from services.ai.langgraph.nodes.plan_formatter_node import WEEKLY_FORMATTER_SYSTEM_PROMPT -from services.ai.langgraph.nodes.season_formatter_node import season_formatter_node -from services.ai.langgraph.nodes.season_planner_node import ( - SEASON_PLANNER_SYSTEM_PROMPT, - SEASON_PLANNER_UPDATE_ONLY_PROMPT, - SEASON_PLANNER_USER_PROMPT, -) -from services.ai.langgraph.nodes.weekly_formatter_node import WEEKLY_FORMATTER_USER_PROMPT_TEMPLATE -from services.ai.langgraph.nodes.weekly_planner_node import ( - WEEKLY_PLANNER_FINAL_CHECKLIST, - WEEKLY_PLANNER_SYSTEM_PROMPT, - WEEKLY_PLANNER_USER_PROMPT, -) -from services.ai.recap.weekly_recap_agent import RECAP_SYSTEM_PROMPT - - -def test_weekly_planner_prompt_requires_segment_zone_targets(): - combined_prompt = f"{WEEKLY_PLANNER_SYSTEM_PROMPT} {WEEKLY_PLANNER_USER_PROMPT} {WEEKLY_PLANNER_FINAL_CHECKLIST}" - - assert "intervals, laps, reps, or changing segments" in combined_prompt - assert "calendar session" in combined_prompt - assert "lap/rep/segment" in combined_prompt - - -def test_planning_prompts_treat_custom_instructions_as_requirements(): - combined_prompt = ( - f"{SEASON_PLANNER_USER_PROMPT} " - f"{SEASON_PLANNER_UPDATE_ONLY_PROMPT} " - f"{WEEKLY_PLANNER_USER_PROMPT} " - f"{WEEKLY_PLANNER_FINAL_CHECKLIST}" - ) - - assert "Planning Context and Custom Instructions" in combined_prompt - assert "Honor Custom Instructions" in combined_prompt - assert "challenge requests" in combined_prompt - assert "visibly reflected or" in combined_prompt - assert "Do NOT reuse an existing season plan" in combined_prompt - - -def test_planning_prompts_create_and_execute_creative_micro_challenges(): - season_prompt = f"{SEASON_PLANNER_SYSTEM_PROMPT} {SEASON_PLANNER_USER_PROMPT} {SEASON_PLANNER_UPDATE_ONLY_PROMPT}" - weekly_prompt = f"{WEEKLY_PLANNER_USER_PROMPT} {WEEKLY_PLANNER_FINAL_CHECKLIST}" - - assert "Creative challenge architecture" in season_prompt - assert "weekly micro-challenges and larger signature/breakthrough challenges" in season_prompt - assert "Do not hardcode stock challenges" in season_prompt - assert "purpose, timing window, progression target, and safety" in season_prompt - assert "lacks a competition-aware creative challenge thread" in season_prompt - assert "push my limits" in season_prompt - assert "psychologically" in season_prompt - assert "protected by safety caps" in season_prompt - assert "NOT just a normal race-distance goal" in season_prompt - assert "weird, memorable constraint system" in season_prompt - - assert "Activate Challenge Architecture" in weekly_prompt - assert "1-2 micro-challenge moments per week" in weekly_prompt - assert "success condition, safety cap, and fallback version" in weekly_prompt - assert "larger signature/breakthrough challenge" in weekly_prompt - assert "Do not shrink every big challenge into a tiny" in weekly_prompt - assert "plain distance goals" in weekly_prompt - assert "non-standard constraint" in weekly_prompt - assert "season-level micro/signature challenges are translated" in weekly_prompt - - -def test_weekly_planner_prompt_requires_transition_continuity(): - combined_prompt = f"{WEEKLY_PLANNER_USER_PROMPT} {WEEKLY_PLANNER_FINAL_CHECKLIST}" - - assert "Transition Context" in combined_prompt - assert "Honor Continuity" in combined_prompt - assert "first 3-7 days" in combined_prompt - assert "fresh easy reset" in combined_prompt - assert "transition rationale" in combined_prompt - - -def test_planning_prompts_require_generic_volume_floors_for_capable_athletes(): - season_prompt = f"{SEASON_PLANNER_SYSTEM_PROMPT} {SEASON_PLANNER_UPDATE_ONLY_PROMPT}" - weekly_prompt = f"{WEEKLY_PLANNER_SYSTEM_PROMPT} {WEEKLY_PLANNER_USER_PROMPT} {WEEKLY_PLANNER_FINAL_CHECKLIST}" - - assert "Volume floors" in season_prompt - assert "healthy, ambitious athletes with demonstrated recent load tolerance" in season_prompt - assert "sport-specific volume" in season_prompt - assert "Cross-training is supportive, not a universal substitute" in season_prompt - assert "Long-horizon durability goals" in season_prompt - assert "short sessions as recovery, shakeouts, taper touches" in season_prompt - - assert "Volume floors, not only safety caps" in weekly_prompt - assert "Calibrate Volume Ambition" in weekly_prompt - assert "Protect Specificity" in weekly_prompt - assert "Prefer Easy Volume Before Extra Intensity" in weekly_prompt - assert "healthy, ambitious athletes with demonstrated load tolerance" in weekly_prompt - assert "short sessions are used as recovery/taper/shakeouts" in weekly_prompt - - -def test_expert_prompts_carry_transition_context_to_weekly_planner(): - combined_prompt = f"{METRICS_USER_PROMPT} {ACTIVITY_EXPERT_USER_PROMPT} {PHYSIOLOGY_USER_PROMPT}" - - assert "Transition Context for Planning Continuity" in combined_prompt - assert "Continuity Requirement" in combined_prompt - assert "starting load state" in combined_prompt - assert "last meaningful stimuli" in combined_prompt - assert "readiness corridor for the first 3-7 days" in combined_prompt - - -def test_weekly_formatter_prompts_keep_lap_guidance_visible(): - combined_prompt = f"{WEEKLY_FORMATTER_SYSTEM_PROMPT}\n{WEEKLY_FORMATTER_USER_PROMPT_TEMPLATE}" - - assert "per-lap/per-rep" in combined_prompt - assert 'disclosure_mode="inline"' in combined_prompt - assert "immediately visible in the calendar side panel" in combined_prompt - assert "Preserve named challenge elements" in combined_prompt - assert "micro-challenges and larger signature/breakthrough challenges as first-class content" in combined_prompt - assert "success condition, safety cap, and fallback version" in combined_prompt - assert "stepping stone toward a larger signature challenge" in combined_prompt - - -def test_season_formatter_preserves_custom_planning_instructions(): - # Source-level guard because the prompt is intentionally assembled inline in the formatter. - source = inspect.getsource(season_formatter_node) - assert "Priority 3 — Custom planning instructions" in source - assert "challenge requests" in source - assert "Priority 4 — Creative challenge thread" in source - assert "signature/breakthrough challenges" in source - assert "Do not flatten distinctive challenge names" in source - - -def test_coach_prompts_require_self_contained_segment_intensity_guidance(): - combined_prompt = f"{PLAN_MODIFIER_SYSTEM_PROMPT}\n{_TURN_SYSTEM_PROMPT}" - - assert "keep the session self-contained" in combined_prompt - assert "each lap, rep, work block, recovery block, and cool-down" in combined_prompt - assert "calendar session" in combined_prompt - - -def test_coach_turn_prompt_requires_fetching_plan_ids_before_skipping_ops(): - assert "call `get_current_weekly_plan` to obtain the relevant identifiers" in _TURN_SYSTEM_PROMPT - - -def test_coach_turn_prompt_mentions_structured_ui_context(): - assert "structured `ui_context`" in _TURN_SYSTEM_PROMPT - - -def test_coach_turn_prompt_mentions_evidence_profile_limits(): - assert "Read `evidence_profile` from the context pack" in _TURN_SYSTEM_PROMPT - assert "activity-completeness claims are unsupported" in _TURN_SYSTEM_PROMPT - - -def test_daily_and_recap_prompts_require_proxy_handling(): - assert "`evidence_profile` and `claims_policy`" in DAILY_SYSTEM_PROMPT - assert "proxy-only" in DAILY_SYSTEM_PROMPT - assert "`evidence_profile` and `claims_policy`" in RECAP_SYSTEM_PROMPT - assert "recovery certainty is limited" in RECAP_SYSTEM_PROMPT - - -def test_season_and_weekly_planner_prompts_require_declared_only_boundaries(): - combined_prompt = f"{SEASON_PLANNER_USER_PROMPT}\n{SEASON_PLANNER_UPDATE_ONLY_PROMPT}\n{WEEKLY_PLANNER_USER_PROMPT}" - - assert "Training Evidence Profile" in combined_prompt - assert "should_acknowledge_no_connected_sources" in combined_prompt - assert "declared profile, goals, competitions, and custom notes only" in combined_prompt - assert ( - "do not claim recent activity history, training load, compliance, sleep, HRV, recovery, or readiness trends" - in combined_prompt - ) - assert "unavailable rather than inferred" in combined_prompt - - -def test_daily_and_coach_prompts_use_subjective_check_ins_and_volume_floors(): - combined_prompt = f"{DAILY_SYSTEM_PROMPT}\n{_TURN_SYSTEM_PROMPT}" - - assert "Subjective athlete check-ins are real coaching evidence" in DAILY_SYSTEM_PROMPT - assert "Use them alongside device data" in DAILY_SYSTEM_PROMPT - assert "weekly sport-specific volume floor" in DAILY_SYSTEM_PROMPT - assert "low-risk adjustment such as easy volume" in DAILY_SYSTEM_PROMPT - assert "Treat subjective check-ins" in _TURN_SYSTEM_PROMPT - assert "Volume-floor awareness" in _TURN_SYSTEM_PROMPT - assert "protect sport-specific volume" in _TURN_SYSTEM_PROMPT - assert "Strong readiness does not automatically mean extra intensity" in combined_prompt - - -def test_expert_prompts_distinguish_provider_native_load_and_proxy_physiology(): - assert "vendor-specific load proxy" not in METRICS_SYSTEM_PROMPT_BASE - assert "provider-native load series" in METRICS_SYSTEM_PROMPT_BASE - assert "proxy-only activity stress signals" in PHYSIOLOGY_SYSTEM_PROMPT_BASE diff --git a/tests/test_version_governance.py b/tests/test_version_governance.py new file mode 100644 index 0000000..1cbe7da --- /dev/null +++ b/tests/test_version_governance.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from scripts import check_version_governance + + +def _write_release_files(root: Path, *, pixi_version: str) -> None: + (root / "web/app").mkdir(parents=True) + (root / "pyproject.toml").write_text('[project]\nversion = "2.2.0"\n', encoding="utf-8") + (root / "pixi.toml").write_text(f'[project]\nversion = "{pixi_version}"\n', encoding="utf-8") + (root / "web/app/package.json").write_text(json.dumps({"version": "2.2.0"}), encoding="utf-8") + + +def test_release_consistency_includes_pixi_manifest(monkeypatch, tmp_path: Path): + _write_release_files(tmp_path, pixi_version="2.2.0") + monkeypatch.setattr(check_version_governance, "REPO_ROOT", tmp_path) + monkeypatch.setattr( + check_version_governance, + "get_version_manifest", + lambda: SimpleNamespace(release=SimpleNamespace(version="2.2.0")), + ) + + assert check_version_governance._check_release_consistency() == [] + + +def test_release_consistency_rejects_pixi_version_drift(monkeypatch, tmp_path: Path): + _write_release_files(tmp_path, pixi_version="2.1.0") + monkeypatch.setattr(check_version_governance, "REPO_ROOT", tmp_path) + monkeypatch.setattr( + check_version_governance, + "get_version_manifest", + lambda: SimpleNamespace(release=SimpleNamespace(version="2.2.0")), + ) + + assert check_version_governance._check_release_consistency() == [ + "Release version mismatch: manifest=2.2.0, pixi.toml=2.1.0" + ] diff --git a/tests/test_version_manifest.py b/tests/test_version_manifest.py index 1263340..2225e21 100644 --- a/tests/test_version_manifest.py +++ b/tests/test_version_manifest.py @@ -4,6 +4,7 @@ VersionManifest, get_default_schema_version, get_supported_schema_versions, + get_supported_schema_versions_for_kind, get_version_manifest, ) @@ -13,6 +14,8 @@ def test_version_manifest_loads(): assert manifest.release.version assert manifest.components.db_schema.alembic_head assert get_default_schema_version() in get_supported_schema_versions() + assert get_supported_schema_versions_for_kind("analysis") == [1] + assert get_supported_schema_versions_for_kind("season") == [1, 3] def test_version_manifest_rejects_invalid_semver(): diff --git a/tests/test_weekly_recap_agent.py b/tests/test_weekly_recap_agent.py new file mode 100644 index 0000000..8a41207 --- /dev/null +++ b/tests/test_weekly_recap_agent.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from services.ai.head_coach.artifacts import NarrativeBlock +from services.ai.head_coach.schemas import RunProfileName +from services.ai.recap import weekly_recap_agent +from services.ai.recap.schemas import WeeklyRecapNarrative + + +@dataclass +class _FakeTool: + name: str + + +class _ProviderFreeRegistry: + def __init__(self): + self.allowed_tool_names: set[str] | None = None + + @classmethod + def registered_tool_names(cls) -> set[str]: + return {"get_athlete_profile", "get_current_weekly_plan"} + + def get_observability_snapshot(self) -> dict: + return {"source_of_truth": "local_athlete_owned"} + + def create_langchain_tools(self, *, allowed_tool_names=None) -> list: + self.allowed_tool_names = set(allowed_tool_names or set()) + return [_FakeTool(name=name) for name in sorted(self.allowed_tool_names)] + + +def _block(key: str, content: str) -> NarrativeBlock: + return NarrativeBlock(block_id=key, markdown=content) + + +@pytest.mark.asyncio +async def test_weekly_recap_uses_shared_head_coach_without_provider_tools(monkeypatch): + registry = _ProviderFreeRegistry() + fake_agent = object() + factory_calls: list[dict] = [] + invoke_calls: list[dict] = [] + expected = WeeklyRecapNarrative( + this_week_blocks=[_block("week", "Two planned sessions completed")], + looking_ahead_blocks=[_block("ahead", "Keep the next quality day protected")], + follow_up_question="How controlled did the final tempo block feel?", + ) + + def fake_build(**kwargs): + factory_calls.append(kwargs) + return fake_agent + + async def fake_invoke(**kwargs): + invoke_calls.append(kwargs) + return expected + + monkeypatch.setattr(weekly_recap_agent, "build_head_coach_agent", fake_build) + monkeypatch.setattr(weekly_recap_agent, "invoke_head_coach_agent", fake_invoke) + + result = await weekly_recap_agent.generate_weekly_recap_narrative( + tool_registry=registry, + week_start_iso="2026-07-13T00:00:00Z", + week_end_iso="2026-07-19T23:59:59Z", + trigger_source="manual", + ) + + assert result is expected + assert factory_calls[0]["profile_name"] is RunProfileName.WEEKLY_RECAP + assert factory_calls[0]["response_schema"] is WeeklyRecapNarrative + assert registry.allowed_tool_names == {"get_athlete_profile", "get_current_weekly_plan"} + assert "athlete profile" in invoke_calls[0]["user_prompt"] diff --git a/tests/test_worker_analysis_results.py b/tests/test_worker_analysis_results.py index 294dfa0..445c1ec 100644 --- a/tests/test_worker_analysis_results.py +++ b/tests/test_worker_analysis_results.py @@ -61,7 +61,7 @@ def model_dump(self, *args, **kwargs): "execution_time_seconds": 123.4, "total_cost_usd": 0.0, "total_tokens": 0, - "node_timings_seconds": {"analysis_formatter": 91.4}, + "node_timings_seconds": {"head_coach_designing_strategy": 91.4}, }, } @@ -210,163 +210,6 @@ def fake_execute(statement): return fake_session -def test_run_override_contexts_propagate_planning_notes_to_experts_and_planners(): - os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") - os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/db") - - from worker.tasks import _build_run_override_contexts - - analysis_context, planning_context = _build_run_override_contexts( - { - "analysis_notes": "Legs feel stale.", - "planning_notes": "Add one playful hill challenge each week.", - "temporary_constraints": "No gym access.", - } - ) - - assert "Run overrides (analysis focus)" in analysis_context - assert "Custom planning instructions for downstream planner fields" in analysis_context - assert "Add one playful hill challenge each week." in analysis_context - assert "`for_season_planner` and `for_weekly_planner`" in analysis_context - assert "Custom planning instructions for this run" in planning_context - assert "must preserve unless unsafe" in planning_context - assert "Temporary constraints for this run (must constrain analysis and planning)" in analysis_context - assert "Temporary constraints for this run (must constrain analysis and planning)" in planning_context - - -def test_worker_serializes_plan_blocks_into_job_result(): - os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") - os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/db") - job_id = uuid.uuid4() - fake_job = _make_fake_job(job_id) - fake_strava_creds = _make_fake_strava_credentials(fake_job.user_id) - - fake_active_analysis = types.SimpleNamespace( - version=1, - analysis_data=None, - expert_context=None, - source_job_id=None, - ) - fake_active_season = types.SimpleNamespace( - version=1, - plan_data=None, - source_job_id=None, - ) - fake_active_weekly = types.SimpleNamespace( - version=1, - plan_data=None, - source_job_id=None, - ) - fake_session = _build_fake_session( - fake_job=fake_job, - fake_strava_creds=fake_strava_creds, - fake_active_analysis=fake_active_analysis, - fake_active_season=fake_active_season, - fake_active_weekly=fake_active_weekly, - ) - - from worker.tasks import run_analysis_task - - def fake_asyncio_run(coro): - if asyncio.iscoroutine(coro): - coro.close() - return _fake_workflow_result() - - with patch("worker.tasks.get_sync_session", return_value=fake_session): - with patch( - "worker.tasks._extract_strava_snapshot", - return_value={ - "athlete_profile": {}, - "recent_activities": [], - "training_load_history": [], - "activity_summary": {"activity_count": 0}, - }, - ): - with patch("worker.tasks.asyncio.run", side_effect=fake_asyncio_run): - run_analysis_task(str(job_id)) - - assert fake_job.status == JobStatus.COMPLETED.value - assert isinstance(fake_job.result, dict) - assert fake_job.result["analysis_blocks"]["analysis_id"] == "analysis_1" - assert fake_job.result["weekly_plan_blocks"]["plan_id"] == "weekly_1" - assert fake_job.result["season_plan_blocks"]["plan_id"] == "season_1" - assert fake_job.result["execution_metadata"]["node_timings_seconds"] == {"analysis_formatter": 91.4} - assert "planning_html" not in fake_job.result - - assert fake_active_analysis.version == 2 - assert fake_active_season.version == 2 - assert fake_active_weekly.version == 2 - assert fake_active_analysis.analysis_data == {"type": "analysis", "analysis_id": "analysis_1", "version": 2} - assert fake_active_season.plan_data == {"type": "season_plan", "plan_id": "season_1", "version": 2} - assert fake_active_weekly.plan_data == {"type": "weekly_plan", "plan_id": "weekly_1", "version": 2} - assert isinstance(fake_active_analysis.expert_context, dict) - assert "metrics_outputs" in fake_active_analysis.expert_context - - -def test_worker_passes_transition_context_with_active_weekly_plan_to_workflow(): - os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") - os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/db") - job_id = uuid.uuid4() - fake_job = _make_fake_job(job_id) - fake_strava_creds = _make_fake_strava_credentials(fake_job.user_id) - fake_active_weekly = _make_fake_active_weekly_plan() - fake_session = _build_fake_session( - fake_job=fake_job, - fake_strava_creds=fake_strava_creds, - fake_active_weekly=fake_active_weekly, - ) - - captured_kwargs = {} - - async def fake_run_complete_analysis_and_planning(**kwargs): - captured_kwargs.update(kwargs) - return _fake_workflow_result() - - from worker.tasks import run_analysis_task - - with patch("worker.tasks.get_sync_session", return_value=fake_session): - with patch( - "worker.tasks._extract_strava_snapshot", - return_value={ - "athlete_profile": {}, - "recent_activities": [ - { - "name": "Morning Easy", - "sport_type": "Run", - "start_date": "2026-05-11T08:00:00Z", - "distance": 5000, - "moving_time": 1800, - "average_heartrate": 132, - } - ], - "training_load_history": [ - { - "date": "2026-05-11", - "activity_count": 1, - "load_value": 8, - "load_type": "strava_relative_effort", - } - ], - "activity_summary": {"activity_count": 1}, - }, - ): - with patch( - "worker.tasks.run_complete_analysis_and_planning", - side_effect=fake_run_complete_analysis_and_planning, - ): - run_analysis_task(str(job_id)) - - assert fake_job.status == JobStatus.COMPLETED.value - assert "transition_context" in captured_kwargs - assert "Recent Executed Sessions" in captured_kwargs["transition_context"] - assert "Morning Easy" in captured_kwargs["transition_context"] - assert "Existing Active Weekly Plan" in captured_kwargs["transition_context"] - assert "Easy aerobic run" in captured_kwargs["transition_context"] - assert "intensity=rest" in captured_kwargs["transition_context"] - assert "first 3-7 days" in captured_kwargs["transition_context"] - assert captured_kwargs["existing_weekly_plan"] is not None - - def test_worker_marks_job_failed_on_soft_time_limit(monkeypatch): monkeypatch.setenv("ANALYSIS_TASK_TIME_LIMIT_SECONDS", "1800") monkeypatch.setenv("ANALYSIS_TASK_SOFT_TIME_LIMIT_SECONDS", "1770") @@ -379,30 +222,31 @@ def test_worker_marks_job_failed_on_soft_time_limit(monkeypatch): from worker.tasks import run_analysis_task - def fake_asyncio_run(coro): + def fail_head_coach_run(coro): if asyncio.iscoroutine(coro): coro.close() raise SoftTimeLimitExceeded() with patch("worker.tasks.get_sync_session", return_value=fake_session): - with patch( - "worker.tasks._extract_strava_snapshot", - return_value={ - "athlete_profile": {}, - "recent_activities": [], - "training_load_history": [], - "activity_summary": {"activity_count": 0}, - }, - ): - with patch("worker.tasks.asyncio.run", side_effect=fake_asyncio_run): - with pytest.raises(SoftTimeLimitExceeded): - run_analysis_task(str(job_id)) + with patch("worker.tasks._run_async_in_worker_loop", side_effect=fail_head_coach_run): + with pytest.raises(SoftTimeLimitExceeded): + run_analysis_task(str(job_id)) assert fake_job.status == JobStatus.FAILED.value assert fake_job.completed_at is not None assert fake_job.error_message == "Job timed out (soft time limit exceeded after 1770s)" +def test_worker_formats_openai_insufficient_quota_as_actionable_error(): + from worker.tasks import _format_analysis_task_error_message + + error = RuntimeError("Required AI stage failed: OpenAI error code: insufficient_quota") + + assert _format_analysis_task_error_message(error) == ( + "OpenAI API quota exhausted. Add billing credit to the configured OpenAI account, then retry." + ) + + def test_worker_defers_terminal_failure_while_autoretry_attempts_remain(): from worker.tasks import _should_defer_failure_to_autoretry diff --git a/tests/test_worker_beat_schedule.py b/tests/test_worker_beat_schedule.py new file mode 100644 index 0000000..e7f34cf --- /dev/null +++ b/tests/test_worker_beat_schedule.py @@ -0,0 +1,29 @@ +import os + +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") + +from worker.celery_app import celery_app + + +def test_beat_schedule_contains_only_current_maintenance_tasks(): + schedule = celery_app.conf.beat_schedule + + assert set(schedule) == { + "run-nightly-coach-memory-compaction", + "run-coach-idempotency-cleanup", + "run-head-coach-checkpoint-cleanup", + "recover-pending-analysis-dispatches", + } + assert ( + schedule["run-nightly-coach-memory-compaction"]["task"] + == "worker.tasks.run_nightly_coach_memory_compaction_task" + ) + assert schedule["run-coach-idempotency-cleanup"]["task"] == "worker.tasks.run_coach_idempotency_cleanup_task" + assert ( + schedule["run-head-coach-checkpoint-cleanup"]["task"] + == "worker.tasks.run_head_coach_checkpoint_cleanup_task" + ) + assert ( + schedule["recover-pending-analysis-dispatches"]["task"] + == "worker.tasks.recover_pending_analysis_dispatches_task" + ) diff --git a/tests/test_worker_head_coach.py b/tests/test_worker_head_coach.py new file mode 100644 index 0000000..d372a78 --- /dev/null +++ b/tests/test_worker_head_coach.py @@ -0,0 +1,121 @@ +import os +import uuid +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/db") + +from api.models.active_season_plan import ActiveSeasonPlan +from api.models.job import AnalysisJob, JobStatus +from worker.tasks import ( + _artifact_commit_already_completed, + _handle_analysis_task_exception, + _persist_head_coach_interrupt, + _prepare_analysis_job_for_run, +) + + +def test_completed_job_requires_both_active_plan_commit_receipts(): + job_id = uuid.uuid4() + completed_job = SimpleNamespace( + status=JobStatus.COMPLETED.value, + cancel_requested_at=None, + ) + matching_season = SimpleNamespace(source_job_id=job_id) + + with pytest.raises(RuntimeError, match="no matching active-plan commit receipt"): + _artifact_commit_already_completed( + current_job=cast("AnalysisJob", completed_job), + season_row=cast("ActiveSeasonPlan", matching_season), + weekly_row=None, + job_uuid=job_id, + ) + + +def test_worker_persists_clarification_without_completing_job(): + job = SimpleNamespace( + status=JobStatus.RUNNING.value, + config={"_workflow_version": "head_coach_v1"}, + ) + db = MagicMock() + db.execute.return_value.scalar_one.return_value = job + + paused = _persist_head_coach_interrupt( + db, + job_uuid=uuid.uuid4(), + result={ + "__interrupt__": ( + SimpleNamespace( + value={ + "question": "Which days are available?", + "reason_markdown": "This changes the weekly structure.", + "requested_field": "availability", + } + ), + ) + }, + ) + + assert paused is True + assert job.status == JobStatus.AWAITING_INPUT.value + assert job.config["_head_coach_interrupt"]["requested_field"] == "availability" + db.commit.assert_called_once_with() + + +def test_duplicate_delivery_cannot_resurrect_completed_or_paused_job(): + db = MagicMock() + completed = SimpleNamespace(status=JobStatus.COMPLETED.value, config={}) + paused = SimpleNamespace(status=JobStatus.AWAITING_INPUT.value, config={"_head_coach_interrupt": {}}) + + for job in (completed, paused): + assert not _prepare_analysis_job_for_run( + db, + job=cast("AnalysisJob", job), + job_uuid=uuid.uuid4(), + job_id="job-1", + celery_task_id="task-1", + is_initial_draft_run=True, + ) + + db.commit.assert_not_called() + + +def test_duplicate_delivery_cannot_take_over_running_job(): + db = MagicMock() + running = SimpleNamespace( + status=JobStatus.RUNNING.value, + config={"_celery_task_id": "owning-task"}, + ) + + assert not _prepare_analysis_job_for_run( + db, + job=cast("AnalysisJob", running), + job_uuid=uuid.uuid4(), + job_id="job-1", + celery_task_id="duplicate-task", + is_initial_draft_run=False, + ) + + db.commit.assert_not_called() + + +def test_post_commit_checkpoint_failure_does_not_mark_job_failed(): + job = SimpleNamespace(id=uuid.uuid4(), status=JobStatus.RUNNING.value) + db = MagicMock() + db.execute.return_value.scalar_one_or_none.return_value = JobStatus.COMPLETED.value + + _handle_analysis_task_exception( + SimpleNamespace(request=SimpleNamespace(retries=0)), + db, + job=cast("AnalysisJob", job), + job_id=str(job.id), + exc=RuntimeError("checkpoint write failed after domain commit"), + is_initial_draft_run=False, + ) + + db.rollback.assert_called_once_with() + db.expire_all.assert_called_once_with() diff --git a/tests/test_worker_scheduled_tasks.py b/tests/test_worker_scheduled_tasks.py index f72eddf..e563734 100644 --- a/tests/test_worker_scheduled_tasks.py +++ b/tests/test_worker_scheduled_tasks.py @@ -1,5 +1,6 @@ import asyncio import os +import uuid from unittest.mock import patch import pytest @@ -42,6 +43,7 @@ async def capture_loop_id() -> int: [ ("run_nightly_coach_memory_compaction_task", "_run_nightly_coach_memory_compaction_async"), ("run_coach_idempotency_cleanup_task", "_run_coach_idempotency_cleanup_async"), + ("run_head_coach_checkpoint_cleanup_task", "_run_head_coach_checkpoint_cleanup_async"), ], ) def test_scheduled_tasks_use_worker_loop_runner(task_name: str, async_name: str): @@ -61,8 +63,23 @@ def fake_runner(coro): assert called_coro.cr_code is async_func.__code__ -def test_daily_proactive_task_is_disabled(caplog): - caplog.set_level("INFO") - worker_tasks.run_daily_coach_proactive_eval_task() +def test_checkpoint_cleanup_is_scheduled_daily(): + schedule = worker_tasks.celery_app.conf.beat_schedule["run-head-coach-checkpoint-cleanup"] - assert "Daily coach proactive eval is disabled" in caplog.text + assert schedule["task"] == "worker.tasks.run_head_coach_checkpoint_cleanup_task" + + +def test_pending_analysis_dispatch_recovery_reenqueues_durable_intents(): + first_job_id = uuid.uuid4() + second_job_id = uuid.uuid4() + with ( + patch.object(worker_tasks, "get_sync_session") as get_sync_session, + patch.object(worker_tasks.run_analysis_task, "delay") as delay, + ): + session = get_sync_session.return_value.__enter__.return_value + session.execute.return_value.scalars.return_value = [first_job_id, second_job_id] + recovered = worker_tasks.recover_pending_analysis_dispatches_task() + + assert recovered == 2 + assert [call.args[0] for call in delay.call_args_list] == [str(first_job_id), str(second_job_id)] + session.commit.assert_called_once_with() diff --git a/tests/test_worker_weekly_recap_schedule.py b/tests/test_worker_weekly_recap_schedule.py deleted file mode 100644 index e85bc9a..0000000 --- a/tests/test_worker_weekly_recap_schedule.py +++ /dev/null @@ -1,16 +0,0 @@ -import os - -os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") - -from worker.celery_app import celery_app - - -def test_beat_schedule_registered(): - schedule = celery_app.conf.beat_schedule - assert "run-nightly-coach-memory-compaction" in schedule - assert ( - schedule["run-nightly-coach-memory-compaction"]["task"] - == "worker.tasks.run_nightly_coach_memory_compaction_task" - ) - assert "run-coach-idempotency-cleanup" in schedule - assert schedule["run-coach-idempotency-cleanup"]["task"] == "worker.tasks.run_coach_idempotency_cleanup_task" diff --git a/web/app/.env.example b/web/app/.env.example index a986b41..16172de 100644 --- a/web/app/.env.example +++ b/web/app/.env.example @@ -6,9 +6,5 @@ API_BASE_URL=http://localhost:8000 API_FETCH_TIMEOUT_MS=30000 NEXT_PUBLIC_AUTH_MODE=local -# Optional connected-mode feature flags. -NEXT_PUBLIC_STRAVA_OAUTH_ENABLED=false -NEXT_PUBLIC_WHOOP_OAUTH_ENABLED=false - # Local data deletion is disabled by default. NEXT_PUBLIC_ALLOW_LOCAL_DATA_DELETE=false diff --git a/web/app/README.md b/web/app/README.md index c27071e..80cc2b7 100644 --- a/web/app/README.md +++ b/web/app/README.md @@ -55,17 +55,17 @@ Run this from the repository root. It wraps `pixi run dev-all`. - `/` local app entry - `/app` dashboard -- `/app/settings` local readiness plus Strava / WHOOP connector status +- `/app/settings` provider-free runtime status plus protected local reset - `/app/competitions` competitions editor - `/app/new` plan generation - `/app/plan` active plan viewer - `/app/coach` coach chat - `/delete`, `/privacy`, `/terms`, `/support`, `/impressum` local-first public/legal pages -### Connected Mode +### Provider-Free Runtime -Strava and WHOOP are optional data connectors, not login providers. Configure their OAuth values in the root `.env` -only if you want connected daily sync and weekly recap. +External training-data connectors are not part of v2.2.0. The app coaches from profile, goals, races, constraints, +plans, and coach history. ## Learn More diff --git a/web/app/package-lock.json b/web/app/package-lock.json index 2894cd9..3aa36f7 100644 --- a/web/app/package-lock.json +++ b/web/app/package-lock.json @@ -9,9 +9,9 @@ "version": "2.2.0", "dependencies": { "@types/dompurify": "^3.0.5", - "dompurify": "^3.3.1", + "dompurify": "^3.4.12", "lucide-react": "^0.577.0", - "next": "^16.2.9", + "next": "^16.2.12", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", @@ -24,7 +24,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.12", "tailwindcss": "^4", "tsx": "^4.20.5", "typescript": "^5" @@ -47,13 +47,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -62,9 +62,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -72,21 +72,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -103,14 +103,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -120,14 +120,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -137,9 +137,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -147,29 +147,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -179,9 +179,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -189,9 +189,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -199,9 +199,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -209,27 +209,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -239,33 +239,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -273,14 +273,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -299,9 +299,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -958,9 +958,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -968,9 +968,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -980,19 +980,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1002,19 +1002,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1028,9 +1047,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1044,12 +1063,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1060,12 +1082,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1076,12 +1101,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1092,12 +1120,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1108,12 +1139,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1124,12 +1158,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1140,12 +1177,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1156,12 +1196,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1172,204 +1215,244 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1379,16 +1462,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1398,16 +1481,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1417,7 +1500,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1487,15 +1570,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", - "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "license": "MIT", "dependencies": { @@ -1503,9 +1586,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", - "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "cpu": [ "arm64" ], @@ -1519,9 +1602,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", - "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "cpu": [ "x64" ], @@ -1535,12 +1618,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", - "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1551,12 +1637,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", - "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1567,12 +1656,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", - "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1583,12 +1675,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", - "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1599,9 +1694,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", - "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "cpu": [ "arm64" ], @@ -1615,9 +1710,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", - "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "cpu": [ "x64" ], @@ -1678,23 +1773,6 @@ "node": ">=12.4.0" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2354,9 +2432,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3013,18 +3091,21 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3046,9 +3127,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -3066,11 +3147,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3140,9 +3221,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "funding": [ { "type": "opencollective", @@ -3477,9 +3558,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3501,9 +3582,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -3831,13 +3912,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.6", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5326,10 +5407,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6714,9 +6805,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -6755,12 +6846,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", - "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.9", + "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -6774,14 +6865,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.9", - "@next/swc-darwin-x64": "16.2.9", - "@next/swc-linux-arm64-gnu": "16.2.9", - "@next/swc-linux-arm64-musl": "16.2.9", - "@next/swc-linux-x64-gnu": "16.2.9", - "@next/swc-linux-x64-musl": "16.2.9", - "@next/swc-win32-arm64-msvc": "16.2.9", - "@next/swc-win32-x64-msvc": "16.2.9", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { @@ -6808,11 +6899,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -7089,55 +7183,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7149,10 +7194,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -7169,7 +7213,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7617,54 +7661,59 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { diff --git a/web/app/package.json b/web/app/package.json index f805d09..c5f0252 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -10,14 +10,14 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "tsx src/tests/fixtures/validate-fixtures.ts && tsx src/tests/security/html-snippet-ssr.tsx && tsx src/tests/coach/coach-turn-sse.ts && tsx src/tests/coach/coach-turn-route.ts && tsx src/tests/api/api-timeout.ts && tsx src/tests/api/daily-run-route.ts && tsx src/tests/oauth/whoop-callback-route.ts && tsx src/tests/oauth/strava-callback-route.ts && tsx src/tests/plan/plan-api-paths.ts && tsx src/tests/plan/day-display.ts && tsx src/tests/plan/calendar-grid.ts && tsx src/tests/plan/season-progress-time.ts && tsx src/tests/dashboard/daily-sync-preview.ts && tsx src/tests/dashboard/header-badges.ts && tsx src/tests/dashboard/no-demo-fallback.ts", + "test": "tsx src/tests/fixtures/validate-fixtures.ts && tsx src/tests/public/messaging-contract.ts && tsx src/tests/onboarding/no-provider-first-run.ts && tsx src/tests/security/html-snippet-ssr.tsx && tsx src/tests/security/local-write-origin.ts && tsx src/tests/coach/coach-turn-sse.ts && tsx src/tests/coach/coach-turn-route.ts && tsx src/tests/coach/legacy-recap-blocks.tsx && tsx src/tests/api/api-timeout.ts && tsx src/tests/plan/plan-api-paths.ts && tsx src/tests/plan/day-display.ts && tsx src/tests/plan/calendar-grid.ts && tsx src/tests/plan/season-progress-time.ts && tsx src/tests/plan/schema-v3-rendering.tsx && tsx src/tests/plan/plan-generation-interrupt.tsx && tsx src/tests/dashboard/v3-dashboard-plan.ts && tsx src/tests/dashboard/no-demo-fallback.ts", "type-check": "tsc --noEmit" }, "dependencies": { "@types/dompurify": "^3.0.5", - "dompurify": "^3.3.1", + "dompurify": "^3.4.12", "lucide-react": "^0.577.0", - "next": "^16.2.9", + "next": "^16.2.12", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", @@ -25,7 +25,8 @@ "remark-gfm": "^4.0.1" }, "overrides": { - "postcss": "8.5.15" + "postcss": "8.5.25", + "sharp": "0.35.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -33,7 +34,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.12", "tailwindcss": "^4", "tsx": "^4.20.5", "typescript": "^5" diff --git a/web/app/public/brands/whoop/whoop-puck-black.svg b/web/app/public/brands/whoop/whoop-puck-black.svg deleted file mode 100644 index 67c9339..0000000 --- a/web/app/public/brands/whoop/whoop-puck-black.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/web/app/public/brands/whoop/whoop-puck-white.svg b/web/app/public/brands/whoop/whoop-puck-white.svg deleted file mode 100644 index 136ca59..0000000 --- a/web/app/public/brands/whoop/whoop-puck-white.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/web/app/public/og.svg b/web/app/public/og.svg index d128bd8..2550ff7 100644 --- a/web/app/public/og.svg +++ b/web/app/public/og.svg @@ -11,17 +11,18 @@ paced.coach - Stop rewriting - your plan. - Season roadmap, 28-day plan, daily coaching. + A complete AI coach. + No wearable required. + Your context → season roadmap → 28-day plan → coach chat + Runs locally with your own LLM key. - - - For self-coached endurance athletes + + + For self-coached endurance athletes - + (`/api/coach/turn`, { - method: "POST", - timeoutMs: null, - body: { - action: "recap", - idempotency_key: idempotencyKey, - message: "Generate my weekly recap.", - } - }); - - revalidatePath("/app/plan"); - revalidatePath("/app"); - return res; -} diff --git a/web/app/src/app/app/api/account/strava/disconnect/route.ts b/web/app/src/app/app/api/account/strava/disconnect/route.ts deleted file mode 100644 index 47cef0b..0000000 --- a/web/app/src/app/app/api/account/strava/disconnect/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { apiFetch } from "@/lib/api"; -import { NextResponse } from "next/server"; - -export async function POST() { - const data = await apiFetch("/api/account/strava/disconnect", { method: "POST" }); - return NextResponse.json(data); -} diff --git a/web/app/src/app/app/api/account/whoop/disconnect/route.ts b/web/app/src/app/app/api/account/whoop/disconnect/route.ts deleted file mode 100644 index 8886b73..0000000 --- a/web/app/src/app/app/api/account/whoop/disconnect/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { apiFetch } from "@/lib/api"; -import { NextResponse } from "next/server"; - -export async function POST() { - const data = await apiFetch("/api/account/whoop/disconnect", { method: "POST" }); - return NextResponse.json(data); -} - diff --git a/web/app/src/app/app/api/analysis/[jobId]/resume/route.ts b/web/app/src/app/app/api/analysis/[jobId]/resume/route.ts new file mode 100644 index 0000000..92db1e2 --- /dev/null +++ b/web/app/src/app/app/api/analysis/[jobId]/resume/route.ts @@ -0,0 +1,20 @@ +import { apiFetch } from "@/lib/api"; +import { apiProxyErrorResponse } from "@/lib/route_helpers"; +import { NextResponse } from "next/server"; + +export async function POST( + req: Request, + context: { params: Promise<{ jobId: string }> }, +) { + try { + const params = await context.params; + const body = await req.json(); + const data = await apiFetch(`/api/analysis/${params.jobId}/resume`, { + method: "POST", + body, + }); + return NextResponse.json(data, { status: 202 }); + } catch (err) { + return apiProxyErrorResponse(err); + } +} diff --git a/web/app/src/app/app/api/coach/turn/route.ts b/web/app/src/app/app/api/coach/turn/route.ts index b10d6a4..0978264 100644 --- a/web/app/src/app/app/api/coach/turn/route.ts +++ b/web/app/src/app/app/api/coach/turn/route.ts @@ -9,7 +9,7 @@ export async function proxyCoachTurn(req: Request, deps?: Partial controller?.abort(), timeoutMs); let upstream: Response; diff --git a/web/app/src/app/app/api/daily/run/route.ts b/web/app/src/app/app/api/daily/run/route.ts deleted file mode 100644 index fb9e7d2..0000000 --- a/web/app/src/app/app/api/daily/run/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { NextResponse } from "next/server"; - -import { apiFetch } from "@/lib/api"; -import { apiProxyErrorResponse } from "@/lib/route_helpers"; - -export const maxDuration = 900; - -function normalizeAthleteCheckIn(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const normalized = value.trim(); - return normalized ? normalized.slice(0, 2000) : undefined; -} - -async function readRequestBody(request: Request): Promise<{ athlete_check_in?: unknown }> { - const rawBody = await request.text(); - if (!rawBody.trim()) return {}; - - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - throw new Response(JSON.stringify({ detail: "Daily sync requests must be JSON." }), { - status: 415, - headers: { "Content-Type": "application/json" }, - }); - } - - try { - const parsed = JSON.parse(rawBody) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Daily sync request body must be a JSON object."); - } - return parsed as { athlete_check_in?: unknown }; - } catch (error) { - throw new Response( - JSON.stringify({ - detail: error instanceof Error ? error.message : "Invalid daily sync JSON body.", - }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} - -export async function POST(request: Request) { - try { - const requestBody = await readRequestBody(request); - const athleteCheckIn = normalizeAthleteCheckIn(requestBody.athlete_check_in); - const data = await apiFetch("/api/daily/run", { - method: "POST", - body: athleteCheckIn ? { athlete_check_in: athleteCheckIn } : {}, - timeoutMs: null, - }); - return NextResponse.json(data); - } catch (err) { - if (err instanceof Response) return err; - return apiProxyErrorResponse(err); - } -} diff --git a/web/app/src/app/app/api/integrations/status/route.ts b/web/app/src/app/app/api/integrations/status/route.ts deleted file mode 100644 index ce1cb60..0000000 --- a/web/app/src/app/app/api/integrations/status/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { apiFetch } from "@/lib/api"; -import { NextResponse } from "next/server"; - -export async function GET() { - const data = await apiFetch("/api/integrations/status", { method: "GET" }); - return NextResponse.json(data); -} - diff --git a/web/app/src/app/app/api/oauth/strava/callback/route.ts b/web/app/src/app/app/api/oauth/strava/callback/route.ts deleted file mode 100644 index ee3dfe7..0000000 --- a/web/app/src/app/app/api/oauth/strava/callback/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; - -type StravaOauthCallbackDeps = { - apiBaseUrl?: string; - fetchFn?: typeof fetch; -}; - -function buildCompletionRedirectUrl(requestUrl: URL, { success }: { success: boolean }): URL { - const redirectUrl = new URL("/strava/complete", requestUrl); - redirectUrl.searchParams.set(success ? "connected" : "oauth_error", "strava"); - return redirectUrl; -} - -export async function proxyStravaOauthCallback(req: Request, deps?: StravaOauthCallbackDeps) { - const url = new URL(req.url); - const qs = url.searchParams.toString(); - const apiBase = (deps?.apiBaseUrl ?? process.env.API_BASE_URL ?? "http://localhost:8000").replace(/\/+$/, ""); - const upstream = qs ? `${apiBase}/api/oauth/strava/callback?${qs}` : `${apiBase}/api/oauth/strava/callback`; - const res = await (deps?.fetchFn ?? fetch)(upstream, { - method: "GET", - redirect: "manual", - cache: "no-store", - }); - - return NextResponse.redirect(buildCompletionRedirectUrl(url, { success: res.ok })); -} - -export async function GET(req: Request) { - return proxyStravaOauthCallback(req); -} diff --git a/web/app/src/app/app/api/oauth/strava/start/route.ts b/web/app/src/app/app/api/oauth/strava/start/route.ts deleted file mode 100644 index 53cc2dd..0000000 --- a/web/app/src/app/app/api/oauth/strava/start/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { backendAuthHeaders } from "@/lib/server-auth"; - -export async function GET() { - let authHeaders: Record; - try { - authHeaders = await backendAuthHeaders(); - } catch { - return new Response(JSON.stringify({ detail: "Not authenticated" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - const apiBase = (process.env.API_BASE_URL ?? "http://localhost:8000").replace(/\/+$/, ""); - return fetch(`${apiBase}/api/oauth/strava/start`, { - method: "GET", - headers: authHeaders, - redirect: "manual", - cache: "no-store", - }); -} diff --git a/web/app/src/app/app/api/oauth/whoop/callback/route.ts b/web/app/src/app/app/api/oauth/whoop/callback/route.ts deleted file mode 100644 index 187bba3..0000000 --- a/web/app/src/app/app/api/oauth/whoop/callback/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; - -type WhoopOauthCallbackDeps = { - apiBaseUrl?: string; - fetchFn?: typeof fetch; -}; - -function buildCompletionRedirectUrl(requestUrl: URL, { success }: { success: boolean }): URL { - const redirectUrl = new URL("/whoop/complete", requestUrl); - redirectUrl.searchParams.set(success ? "connected" : "oauth_error", "whoop"); - return redirectUrl; -} - -export async function proxyWhoopOauthCallback(req: Request, deps?: WhoopOauthCallbackDeps) { - const url = new URL(req.url); - const qs = url.searchParams.toString(); - const apiBase = (deps?.apiBaseUrl ?? process.env.API_BASE_URL ?? "http://localhost:8000").replace(/\/+$/, ""); - const upstream = qs ? `${apiBase}/api/oauth/whoop/callback?${qs}` : `${apiBase}/api/oauth/whoop/callback`; - const res = await (deps?.fetchFn ?? fetch)(upstream, { - method: "GET", - redirect: "manual", - cache: "no-store", - }); - - return NextResponse.redirect(buildCompletionRedirectUrl(url, { success: res.ok })); -} - -export async function GET(req: Request) { - return proxyWhoopOauthCallback(req); -} diff --git a/web/app/src/app/app/api/oauth/whoop/start/route.ts b/web/app/src/app/app/api/oauth/whoop/start/route.ts deleted file mode 100644 index 7fc7ae9..0000000 --- a/web/app/src/app/app/api/oauth/whoop/start/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { backendAuthHeaders } from "@/lib/server-auth"; - -export async function GET() { - let authHeaders: Record; - try { - authHeaders = await backendAuthHeaders(); - } catch { - return new Response(JSON.stringify({ detail: "Not authenticated" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - const apiBase = (process.env.API_BASE_URL ?? "http://localhost:8000").replace(/\/+$/, ""); - return fetch(`${apiBase}/api/oauth/whoop/start`, { - method: "GET", - headers: authHeaders, - redirect: "manual", - cache: "no-store", - }); -} diff --git a/web/app/src/app/app/api/weekly-recap/latest/route.ts b/web/app/src/app/app/api/weekly-recap/latest/route.ts deleted file mode 100644 index 480d7ae..0000000 --- a/web/app/src/app/app/api/weekly-recap/latest/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextResponse } from "next/server"; - -import { apiFetch } from "@/lib/api"; -import { apiProxyErrorResponse } from "@/lib/route_helpers"; - -export async function GET() { - try { - const data = await apiFetch("/api/weekly-recap/latest", { method: "GET" }); - return NextResponse.json(data); - } catch (err) { - return apiProxyErrorResponse(err); - } -} diff --git a/web/app/src/app/app/coach/page.tsx b/web/app/src/app/app/coach/page.tsx index 913da81..a839874 100644 --- a/web/app/src/app/app/coach/page.tsx +++ b/web/app/src/app/app/coach/page.tsx @@ -24,19 +24,19 @@ export default async function CoachInboxPage({ searchParams }: CoachInboxPagePro

{COACH_LABEL}

{COACH_DESCRIPTION}

- Continue conversations, review recap proposals, and ask for schedule changes in one focused workspace. + Continue conversations, review plan proposals, and ask for schedule changes in one focused workspace.

Good for
-
Workout swaps, recovery questions, race planning, and recap follow-ups.
+
Workout swaps, training reflections, race planning, and schedule changes.
Context used
- Your declared profile, goals, current plan, prior coaching outputs, and connected activity/recovery data when available. + Your declared profile, goals, current plan, prior coaching outputs, and what you share in the conversation.
diff --git a/web/app/src/app/app/jobs/[jobId]/page.tsx b/web/app/src/app/app/jobs/[jobId]/page.tsx index a788065..71a4a50 100644 --- a/web/app/src/app/app/jobs/[jobId]/page.tsx +++ b/web/app/src/app/app/jobs/[jobId]/page.tsx @@ -1,10 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import PlanViewer from "@/components/plan-viewer/plan-viewer"; +import type { SeasonPlanV3, WeeklyPlanV3 } from "@/components/plan-viewer/types"; +import CoachClarificationCard from "@/components/jobs/coach-clarification-card"; import type { UiAnalysis, UiSeasonPlan, UiWeeklyPlan } from "@/lib/types/ui-blocks"; type JobStatus = { @@ -16,6 +18,11 @@ type JobStatus = { completed_at?: string | null; cost_usd?: number | null; tokens_used?: number | null; + interrupt?: { + question: string; + reason_markdown: string; + requested_field: string; + } | null; }; type ProgressStep = { @@ -46,48 +53,42 @@ type JobResults = { status: string; result?: { analysis_blocks?: UiAnalysis | null; - weekly_plan_blocks?: UiWeeklyPlan | null; - season_plan_blocks?: UiSeasonPlan | null; + weekly_plan_blocks?: UiWeeklyPlan | WeeklyPlanV3 | null; + season_plan_blocks?: UiSeasonPlan | SeasonPlanV3 | null; } | null; error_message?: string | null; }; const PROGRESS_STAGES: ProgressStage[] = [ { - key: "data", - label: "Collect Data", - description: "Gathering metrics, physiology, and recent activity context.", - nodes: ["metrics_summarizer", "physiology_summarizer", "activity_summarizer"], - }, - { - key: "experts", - label: "Expert Analysis", - description: "Running specialist analysis across your training signals.", - nodes: ["metrics_expert", "physiology_expert", "activity_expert"], + key: "context", + label: "Understand You", + description: "Reading your profile, goals, calendar, constraints, and coaching memory.", + nodes: ["head_coach_understanding_context"], }, { - key: "analysis", - label: "Synthesize Insights", - description: "Combining findings into clear analysis outputs.", - nodes: ["synthesis", "plot_resolution", "analysis_formatter"], + key: "strategy", + label: "Design Strategy", + description: "Building the season direction around what you declared.", + nodes: ["head_coach_designing_strategy"], }, { - key: "planning", - label: "Build Plans", - description: "Designing your season roadmap and next 28 days.", - nodes: ["season_planner", "data_integration", "weekly_planner"], + key: "review", + label: "Review Constraints", + description: "Checking that the plan is coherent, safe, and realistic.", + nodes: ["head_coach_reviewing_constraints", "head_coach_awaiting_input"], }, { - key: "formatting", - label: "Format Delivery", - description: "Preparing your season roadmap and training block for presentation.", - nodes: ["season_formatter", "weekly_formatter"], + key: "execution", + label: "Build 28 Days", + description: "Turning the strategy into a complete daily execution block.", + nodes: ["head_coach_building_execution_block"], }, { - key: "finalize", - label: "Finalize", - description: "Wrapping up generation metadata and publishing results.", - nodes: ["finalize"], + key: "save", + label: "Save Plan", + description: "Publishing the roadmap and execution block to your local app.", + nodes: ["head_coach_saving_plan"], }, ]; @@ -100,7 +101,6 @@ function stageStatusLabel(stageStatus: StageStatus): string { function summarizeProgressStages(progressSteps: ProgressStep[], jobStatus: string | null | undefined): ProgressStageView[] { const stepsByNode = new Map(progressSteps.map((step) => [step.node, step])); - const stages: ProgressStageView[] = PROGRESS_STAGES.map((stage) => { const matchedSteps = stage.nodes .map((nodeName) => stepsByNode.get(nodeName)) @@ -212,6 +212,8 @@ export default function JobPage({ params }: { params: { jobId: string } }) { const [error, setError] = useState(null); const [cancelState, setCancelState] = useState<"idle" | "sending" | "sent" | "error">("idle"); const [cancelMessage, setCancelMessage] = useState(null); + const [pollGeneration, setPollGeneration] = useState(0); + const resumeRecoveryUntilRef = useRef(0); useEffect(() => { let timer: ReturnType | null = null; @@ -234,6 +236,7 @@ export default function JobPage({ params }: { params: { jobId: string } }) { authRetries = 0; const s = (await res.json()) as JobStatus; if (cancelled) return; + setError(null); setStatus(s); if (s.status === "completed" || s.status === "failed") { @@ -246,6 +249,7 @@ export default function JobPage({ params }: { params: { jobId: string } }) { if (!cancelled) timer = setTimeout(poll, 2000); return; } + if (!r.ok) throw new Error(await r.text()); const data = (await r.json()) as JobResults; if (!cancelled) setResults(data); return; @@ -255,9 +259,23 @@ export default function JobPage({ params }: { params: { jobId: string } }) { return; } + if (s.status === "awaiting_input") { + if (Date.now() < resumeRecoveryUntilRef.current) { + timer = setTimeout(poll, 1000); + } + return; + } + + resumeRecoveryUntilRef.current = 0; + timer = setTimeout(poll, 2000); } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : "Failed to fetch status"); + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to fetch status"); + if (Date.now() < resumeRecoveryUntilRef.current) { + timer = setTimeout(poll, 2000); + } + } } } @@ -266,10 +284,13 @@ export default function JobPage({ params }: { params: { jobId: string } }) { cancelled = true; if (timer) clearTimeout(timer); }; - }, [jobId]); + }, [jobId, pollGeneration]); const canCancel = - status?.status === "pending" || status?.status === "running" || status?.status === "cancellation_requested"; + status?.status === "pending" || + status?.status === "running" || + status?.status === "awaiting_input" || + status?.status === "cancellation_requested"; async function onCancel() { setCancelState("sending"); @@ -277,6 +298,17 @@ export default function JobPage({ params }: { params: { jobId: string } }) { try { const res = await fetch(`/app/api/analysis/${jobId}/cancel`, { method: "POST" }); if (!res.ok) throw new Error(await res.text()); + const cancelledStatus = (await res.json()) as Partial; + setStatus((current) => + current + ? { + ...current, + ...cancelledStatus, + status: cancelledStatus.status ?? "cancellation_requested", + interrupt: null, + } + : current, + ); setCancelState("sent"); setCancelMessage("Cancellation requested."); } catch (e) { @@ -317,6 +349,21 @@ export default function JobPage({ params }: { params: { jobId: string } }) {
{error}
) : null} + {status?.status === "awaiting_input" && status.interrupt ? ( + { + setStatus((current) => (current ? { ...current, status: "pending", interrupt: null } : current)); + }} + onStatusRecovery={() => { + setError(null); + resumeRecoveryUntilRef.current = Date.now() + 10_000; + setPollGeneration((current) => current + 1); + }} + /> + ) : null} +
ID: {jobId} @@ -357,7 +404,7 @@ export default function JobPage({ params }: { params: { jobId: string } }) {
Job progress
-
Simplified into 6 stages
+
{stageViews.length} clear coaching stages
{completedStages}/{stageViews.length} complete @@ -414,6 +461,12 @@ export default function JobPage({ params }: { params: { jobId: string } }) {
) : null} + {status?.status === "completed" && results && !hasBlocks && !results.error_message ? ( +
+ Generation completed, but no renderable plan artifact was returned. Your previously saved plan is unchanged. +
+ ) : null} + {hasBlocks ? ( ) { - let integrations: IntegrationsStatus | null = null; let profile: ProfilePayload | null = null; let competitionsCount = 0; - const [integrationsResult, profileResult, competitionsResult] = await Promise.allSettled([ - apiFetch("/api/integrations/status"), + const [profileResult, competitionsResult] = await Promise.allSettled([ apiFetch("/api/athlete-profile"), apiFetch("/api/competitions"), ]); - if (integrationsResult.status === "fulfilled") { - integrations = integrationsResult.value; - } if (profileResult.status === "fulfilled") { profile = profileResult.value.profile; } @@ -37,21 +28,11 @@ export default async function AppLayout({ competitionsCount = competitionsResult.value.length; } - const providerBadge = formatTrainingProviderBadge(integrations); const profileBadge = formatProfileCompletenessBadge(profile, { available: profileResult.status === "fulfilled" }); const competitionsBadge = formatCompetitionsBadge( competitionsResult.status === "fulfilled" ? competitionsCount : null, { available: competitionsResult.status === "fulfilled" }, ); - const providerBadgeClass = !integrations - ? "border-[var(--border-accent)] text-[var(--text-muted)]" - : getAttentionTrainingProviderNames(integrations).length > 0 - ? "border-[var(--accent-warning)]/30 text-[var(--accent-warning)]" - : getPreviouslyConnectedProviderNames(integrations).length > 0 - ? "border-[var(--accent-warning)]/30 text-[var(--accent-warning)]" - : hasOperationalTrainingProvider(integrations) - ? "border-[var(--accent-success)]/30 text-[var(--accent-success)]" - : "border-[var(--border-accent)] text-[var(--text-muted)]"; const profileBadgeClass = profileResult.status === "fulfilled" ? "border-[var(--accent-primary)]/30 text-[var(--accent-primary)]" : "border-[var(--border-accent)] text-[var(--text-muted)]"; @@ -68,7 +49,9 @@ export default async function AppLayout({
- {providerBadge} + + Provider-free + {profileBadge} {competitionsBadge}
diff --git a/web/app/src/app/app/new/page.tsx b/web/app/src/app/app/new/page.tsx index 0d77dde..106af9e 100644 --- a/web/app/src/app/app/new/page.tsx +++ b/web/app/src/app/app/new/page.tsx @@ -4,15 +4,9 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import type { AthleteProfileResponse, Competition, IntegrationsStatus, ProfilePayload } from "@/lib/types/athlete-context"; +import type { AthleteProfileResponse, Competition, ProfilePayload } from "@/lib/types/athlete-context"; import { formatDateHuman, - formatTrainingProviderBadge, - getAttentionTrainingProviderNames, - getPreviouslyConnectedProviderNames, - hasEverConnectedTrainingProvider, - hasLinkedTrainingProvider, - hasOperationalTrainingProvider, profileCompleteness, } from "@/lib/types/athlete-context"; import type { DashboardStateResponse } from "@/lib/types/dashboard"; @@ -48,9 +42,9 @@ export default function NewRunPage() { const [contextState, setContextState] = useState("loading"); const [contextError, setContextError] = useState(null); + const [contextReloadToken, setContextReloadToken] = useState(0); const [profile, setProfile] = useState(null); const [competitions, setCompetitions] = useState([]); - const [integrations, setIntegrations] = useState(null); const [firstRun, setFirstRun] = useState(null); useEffect(() => { @@ -60,27 +54,23 @@ export default function NewRunPage() { setContextState("loading"); setContextError(null); try { - const [profileRes, competitionRes, credentialsRes, dashboardRes] = await Promise.all([ + const [profileRes, competitionRes, dashboardRes] = await Promise.all([ fetch("/app/api/athlete-profile", { cache: "no-store" }), fetch("/app/api/competitions", { cache: "no-store" }), - fetch("/app/api/integrations/status", { cache: "no-store" }), fetch("/app/api/dashboard/state", { cache: "no-store" }), ]); if (!profileRes.ok) throw new Error("Failed to load athlete profile"); if (!competitionRes.ok) throw new Error("Failed to load competitions"); - if (!credentialsRes.ok) throw new Error("Failed to load integrations status"); if (!dashboardRes.ok) throw new Error("Failed to load generation readiness"); const profileData = (await profileRes.json()) as AthleteProfileResponse; const competitionData = (await competitionRes.json()) as Competition[]; - const credentialsData = (await credentialsRes.json()) as IntegrationsStatus; const dashboardData = (await dashboardRes.json()) as DashboardStateResponse; if (cancelled) return; setProfile(profileData.profile); setCompetitions(competitionData); - setIntegrations(credentialsData); setFirstRun(dashboardData.first_run); setContextState("loaded"); } catch (error) { @@ -94,25 +84,11 @@ export default function NewRunPage() { return () => { cancelled = true; }; - }, []); + }, [contextReloadToken]); const completeness = useMemo(() => profileCompleteness(profile), [profile]); const warnings = useMemo(() => { const nextWarnings: string[] = []; - const operationalProviderReady = hasOperationalTrainingProvider(integrations); - if (!operationalProviderReady) { - if (hasLinkedTrainingProvider(integrations)) { - const attentionProviders = getAttentionTrainingProviderNames(integrations); - const attentionSummary = attentionProviders.length > 0 ? attentionProviders.join(" + ") : "A linked provider"; - nextWarnings.push(`${attentionSummary} needs attention. Draft Mode still works, but connected coaching will stay partial until those sources recover.`); - } else if (hasEverConnectedTrainingProvider(integrations)) { - const disconnectedProviders = getPreviouslyConnectedProviderNames(integrations); - const providerSummary = disconnectedProviders.length > 0 ? disconnectedProviders.join(" + ") : "Your training source"; - nextWarnings.push(`${providerSummary} was disconnected. Draft Mode still works, but connected coaching stays partial until a source is reconnected.`); - } else { - nextWarnings.push("No connected training source yet. Draft Mode still works, but planning will rely on your stored profile and race calendar until you connect Strava or WHOOP."); - } - } if (completeness < 65) { nextWarnings.push("Profile context is still sparse, so training constraints may be interpreted too loosely."); } @@ -120,19 +96,19 @@ export default function NewRunPage() { nextWarnings.push("No competitions are saved, so periodization will be more generic."); } if (firstRun && !firstRun.llm_ready) { - nextWarnings.push("No supported LLM key is configured. Add OPENAI_API_KEY to .env and restart the API before generating."); + nextWarnings.push(firstRun.blockers[0] ?? "Add one supported LLM key to .env and restart the API before generating."); } return nextWarnings; - }, [competitions.length, completeness, firstRun, integrations]); + }, [competitions.length, completeness, firstRun]); const llmReady = firstRun?.llm_ready ?? true; - const generationDisabled = state === "starting" || contextState === "loading" || !llmReady; + const generationDisabled = state === "starting" || contextState !== "loaded" || !llmReady; async function onSubmit(e: React.FormEvent) { e.preventDefault(); if (!llmReady) { setState("error"); - setMessage("Add OPENAI_API_KEY to your local .env, restart the API, then retry generation."); + setMessage(firstRun?.blockers[0] ?? "Add one supported LLM key to your local .env, restart the API, then retry generation."); return; } setState("starting"); @@ -165,7 +141,8 @@ export default function NewRunPage() { router.push(`/app/jobs/${jobId}`); } catch (error) { setState("error"); - setMessage(error instanceof Error ? error.message : "Failed to generate plans."); + const detail = error instanceof Error ? error.message : "The generation request failed."; + setMessage(`Plan generation failed. Your saved profile and race context are unchanged. ${detail}`); } } @@ -179,17 +156,28 @@ export default function NewRunPage() {
{contextState === "error" ? ( -
{contextError}
+
+
Saved planning context could not be loaded. Nothing was changed.
+
{contextError}
+ +
) : null}
-
Connected sources: {formatTrainingProviderBadge(integrations)}
+
Planning baseline: your saved athlete context

- Draft Mode uses your stored profile and race calendar immediately. Connected data improves precision and - unlocks richer daily sync and weekly recap context once configured. + No wearable is required. Your saved profile, goals, availability, constraints, race calendar, and notes + are enough to generate the season roadmap and 28-day block.

+
External training-data connectors are not part of this release.
@@ -234,20 +222,16 @@ export default function NewRunPage() { {warnings.length > 0 ? (
-
Draft Mode notes
+
Planning context notes

- You can generate plans now. These gaps mainly reduce precision or limit later connected coaching surfaces. + Generation remains available when the LLM key and saved context are ready. These notes affect specificity + or plan specificity.

    {warnings.map((warning) => (
  • {warning}
  • ))}
- {!hasOperationalTrainingProvider(integrations) ? ( - - Open connected sources - - ) : null}
) : null} diff --git a/web/app/src/app/app/page.tsx b/web/app/src/app/app/page.tsx index 7e54ade..b690517 100644 --- a/web/app/src/app/app/page.tsx +++ b/web/app/src/app/app/page.tsx @@ -1,76 +1,6 @@ import DashboardClient from "@/components/dashboard/dashboard-client"; import { apiFetch } from "@/lib/api"; -import { emptyCoachSurface, type DashboardStateResponse } from "@/lib/types/dashboard"; - -function buildEmptyDashboardState(): DashboardStateResponse { - return { - athlete_time: { - timezone: "UTC", - timezone_source: "fallback_utc", - today_local_date: new Date().toISOString().slice(0, 10), - now_local_iso: new Date().toISOString(), - }, - analysis: null, - status_surface: { - kpis: [], - source: "none", - label: null, - updated_at: null, - target_date: null, - }, - coach_surface: emptyCoachSurface(), - season: null, - weekly: null, - first_run: { - mode: "manual", - evidence_level: "declared_only", - llm_ready: false, - profile_completeness: 0, - profile_ready: false, - goal_ready: false, - has_competitions: false, - has_active_plan: false, - has_connected_source: false, - next_step: "llm_key", - title: "Connect the local backend", - body: "Start the API and add one supported LLM key before generating a local training plan.", - primary_action: null, - secondary_actions: [], - blockers: ["Set API_BASE_URL if your backend is not running on localhost:8000."], - }, - today_mission: { - warnings: [], - day_override: null, - }, - daily_sync: { - visible: false, - status: "idle", - run_id: null, - verdict_preview: null, - sources_used: [], - proposal_id: null, - thread_id: null, - error_message: null, - can_run: false, - attention_message: null, - gate_target: null, - }, - weekly_recap: { - visible: false, - allowed: false, - status: "hidden", - thread_id: null, - proposal_id: null, - follow_up_question: null, - summary_preview: null, - pending_action: "none", - can_run: false, - attention_message: null, - gate_target: null, - }, - pending_proposal_banner: null, - }; -} +import type { DashboardStateResponse } from "@/lib/types/dashboard"; export default async function DashboardPage() { let dashboardState: DashboardStateResponse | null = null; @@ -85,22 +15,18 @@ export default async function DashboardPage() { backendError = error instanceof Error ? error.message : "Failed to reach backend"; } - const initialState = dashboardState ?? buildEmptyDashboardState(); - - return ( - <> - - - {backendError ? ( -
-
Service status
-
- {!backendConfigured - ? "This app shell cannot reach the local training backend yet. Start the API, keep your local database intact, then refresh this page." - : backendError} -
+ if (!dashboardState) { + return ( +
+
Dashboard could not load
+
+ {!backendConfigured + ? "The local API returned an error. Your saved profile and plans are unchanged. Check the API logs, then refresh this page." + : backendError}
- ) : null} - - ); +
+ ); + } + + return ; } diff --git a/web/app/src/app/app/plan/page.tsx b/web/app/src/app/app/plan/page.tsx index 3e7f764..ab1d09f 100644 --- a/web/app/src/app/app/plan/page.tsx +++ b/web/app/src/app/app/plan/page.tsx @@ -2,14 +2,15 @@ import { Suspense } from "react"; import Link from "next/link"; import PlanViewer from "@/components/plan-viewer/plan-viewer"; +import type { SeasonPlanV3, WeeklyPlanV3 } from "@/components/plan-viewer/types"; import { apiFetch } from "@/lib/api"; import { isApiError } from "@/lib/api_errors"; import type { UiAnalysis, UiSeasonPlan, UiWeeklyPlan } from "@/lib/types/ui-blocks"; type ActivePlansBundle = { analysis?: { analysis: UiAnalysis; version: number; updated_at: string; source_job_id: string } | null; - season?: { season_plan: UiSeasonPlan; version: number; updated_at: string; source_job_id: string } | null; - weekly?: { weekly_plan: UiWeeklyPlan; version: number; updated_at: string; source_job_id: string } | null; + season?: { season_plan: UiSeasonPlan | SeasonPlanV3; version: number; updated_at: string; source_job_id: string } | null; + weekly?: { weekly_plan: UiWeeklyPlan | WeeklyPlanV3; version: number; updated_at: string; source_job_id: string } | null; } | null; function PlanViewerFallback() { @@ -23,7 +24,7 @@ function PlanViewerFallback() { export default async function PlanPage() { let activePlans: ActivePlansBundle = null; let backendError: string | null = null; - const renderNowIso = new Date().toISOString(); + let renderNowIso: string | undefined; const apiBaseUrl = (process.env.API_BASE_URL ?? "http://localhost:8000").replace(/\/+$/, ""); const backendConfigured = apiBaseUrl !== "http://localhost:8000"; @@ -40,13 +41,36 @@ export default async function PlanPage() { } } + if (activePlans && !backendError) { + try { + const dashboardClock = await apiFetch<{ athlete_time?: { now_local_iso?: string } }>("/api/dashboard/state"); + renderNowIso = dashboardClock.athlete_time?.now_local_iso; + } catch { + // The plan remains readable using the browser-local clock when the + // richer dashboard state is temporarily unavailable. + } + } + const analysis = activePlans?.analysis?.analysis; const seasonPlan = activePlans?.season?.season_plan; const weeklyPlan = activePlans?.weekly?.weekly_plan; return (
- {analysis || seasonPlan || weeklyPlan ? ( + {backendError ? ( +
+
Plan service unavailable
+

Your saved plan could not be loaded

+

+ {!backendConfigured + ? "The app cannot reach the local training backend. Start the API and refresh this page. Your saved profile and plans are unchanged." + : `${backendError} Your saved profile and plans are unchanged.`} +

+
+ ) : analysis || seasonPlan || weeklyPlan ? ( }> @@ -54,7 +78,8 @@ export default async function PlanPage() {

No active training plan yet

- Generate a local Draft Mode plan from your saved profile, goals, race calendar, and one configured LLM key. + Generate a personal season roadmap and 28-day block from your saved profile, goals, race calendar, and one + configured LLM key. No wearable required.

)} - - {backendError ? ( -
-
Service status
-
- {!backendConfigured - ? "This app shell cannot reach the local training backend yet. Start the API, keep your local database intact, then refresh this page." - : backendError} -
-
- ) : null}
); } diff --git a/web/app/src/app/app/profile/page.tsx b/web/app/src/app/app/profile/page.tsx index 32001e3..ac6bdf9 100644 --- a/web/app/src/app/app/profile/page.tsx +++ b/web/app/src/app/app/profile/page.tsx @@ -299,7 +299,7 @@ export default function AthleteProfilePage() {
Account & Data
Settings

- Manage Strava, WHOOP, sync status, and account deletion from one place. + Review the provider-free runtime and manage the protected local data reset.

@@ -467,7 +467,7 @@ export default function AthleteProfilePage() { ) : null}

- Type or pick an IANA timezone so daily syncs and weekly recap windows reset on your local calendar day. + Type or pick an IANA timezone so plans and calendar dates follow your local day.

{browserTimezone ? (

Detected in this browser: {browserTimezone}

diff --git a/web/app/src/app/app/settings/page.tsx b/web/app/src/app/app/settings/page.tsx index f03556d..4929bb8 100644 --- a/web/app/src/app/app/settings/page.tsx +++ b/web/app/src/app/app/settings/page.tsx @@ -1,240 +1,35 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; - -import WhoopConnectButton from "@/components/whoop/whoop-connect-button"; -import type { IntegrationsStatus } from "@/lib/types/athlete-context"; - -type LoadState = "idle" | "loading" | "loaded" | "error"; -type ProviderStatus = IntegrationsStatus["strava"] | IntegrationsStatus["whoop"]; -type ProviderId = "strava" | "whoop"; -type ProviderName = "Strava" | "WHOOP"; - -function providerHeadline(providerName: ProviderName, status: ProviderStatus | undefined): string { - if (status?.connection_state === "disabled") return `${providerName} connector is disabled.`; - if (status?.connection_state === "unconfigured") return `${providerName} connector is not configured.`; - if (status?.connection_state === "started") return `${providerName} connection is in progress.`; - if (status?.connection_state === "callback_failed") return `${providerName} connection failed.`; - if (!status?.linked && status?.ever_connected) return `${providerName} was disconnected.`; - if (!status?.linked) return `${providerName} is not connected yet.`; - if (status.operational) return `${providerName} is active.`; - return `${providerName} needs attention.`; -} - -function providerSubtext(providerId: ProviderId, providerName: ProviderName, status: ProviderStatus | undefined): string { - if (status?.connection_state === "disabled") { - return `Set ${providerId === "strava" ? "STRAVA_OAUTH_ENABLED" : "WHOOP_OAUTH_ENABLED"}=true when you want to use ${providerName} as an optional data connector.`; - } - if (status?.connection_state === "unconfigured") { - return `Add the local OAuth app credentials and callback URL before connecting ${providerName}.`; - } - if (status?.connection_state === "started") { - return `Complete the provider approval tab, or restart the ${providerName} connection flow if it expired.`; - } - if (status?.connection_state === "callback_failed") { - return status.attention_message ?? `Restart the ${providerName} connection flow from this page.`; - } - if (!status?.linked && status?.ever_connected) { - if (status.last_disconnect_reason === "user_initiated") { - return `Reconnect ${providerName} anytime to resume ${providerId === "strava" ? "activity import" : "readiness import"}.`; - } - return `Reconnect ${providerName} to restore ${providerId === "strava" ? "activity import" : "readiness import"}.`; - } - if (!status?.linked) { - return providerId === "strava" - ? "Connect Strava to import activity history and execution context." - : "Connect WHOOP to import recovery and readiness context."; - } - if (status.operational) { - return providerId === "strava" ? "Imported from Strava." : "Imported from WHOOP."; - } - return status.attention_message ?? `${providerName} is linked, but syncing is not operational right now.`; -} - -function providerStatusTone(status: ProviderStatus | undefined): string { - if ( - status?.state === "attention_needed" || - status?.connection_state === "unconfigured" || - status?.connection_state === "started" || - status?.connection_state === "callback_failed" || - (status?.ever_connected && !status?.linked) - ) { - return "text-amber-400"; - } - return "text-[var(--text-muted)]"; -} - -function ConnectLinkButton({ - enabled, - href, - label, - disabledTitle, -}: { - enabled: boolean; - href: string; - label: string; - disabledTitle: string; -}) { - const className = - "inline-flex items-center rounded-md bg-[#fc4c02] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#e34502] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#fc4c02]"; - if (!enabled) { - return ( - - ); - } - return ( - - {label} - - ); -} - -async function fetchIntegrationsStatus() { - const res = await fetch("/app/api/integrations/status", { cache: "no-store" }); - if (!res.ok) throw new Error(await res.text()); - return (await res.json()) as IntegrationsStatus; -} +import { useState } from "react"; export default function SettingsPage() { - const router = useRouter(); - - const stravaOauthEnabled = (process.env.NEXT_PUBLIC_STRAVA_OAUTH_ENABLED ?? "false") === "true"; - const whoopOauthEnabled = (process.env.NEXT_PUBLIC_WHOOP_OAUTH_ENABLED ?? "false") === "true"; const localDataDeleteAllowed = (process.env.NEXT_PUBLIC_ALLOW_LOCAL_DATA_DELETE ?? "false") === "true"; - const [actionMessage, setActionMessage] = useState(null); - const [disconnecting, setDisconnecting] = useState(null); - const [integrationsState, setIntegrationsState] = useState("idle"); - const [integrationsError, setIntegrationsError] = useState(null); - const [integrations, setIntegrations] = useState(null); - - const canDisconnectStrava = Boolean(integrations?.strava.linked); - const canDisconnectWhoop = Boolean(integrations?.whoop.linked); - const canStartStrava = - stravaOauthEnabled && integrations?.strava.oauth_enabled !== false && integrations?.strava.configured !== false; - const canStartWhoop = - whoopOauthEnabled && integrations?.whoop.oauth_enabled !== false && integrations?.whoop.configured !== false; - - async function refreshIntegrations() { - setIntegrationsState("loading"); - setIntegrationsError(null); - try { - const data = await fetchIntegrationsStatus(); - setIntegrations(data); - setIntegrationsState("loaded"); - return data; - } catch (err) { - setIntegrationsState("error"); - setIntegrationsError(err instanceof Error ? err.message : "Failed to load integration status."); - return null; - } - } - - useEffect(() => { - const { searchParams } = new URL(window.location.href); - const connected = searchParams.get("connected"); - const oauthError = searchParams.get("oauth_error"); - - if (connected === "strava") { - setActionMessage("Strava connected. Activity sync is active."); - router.replace("/app/settings"); - router.refresh(); - return; - } - if (connected === "whoop") { - setActionMessage("WHOOP connected. Readiness sync is active."); - router.replace("/app/settings"); - router.refresh(); - return; - } - if (oauthError === "strava") { - setActionMessage("Strava connection failed. Please try again."); - router.replace("/app/settings"); - return; - } - if (oauthError === "whoop") { - setActionMessage("WHOOP connection failed. Please try again."); - router.replace("/app/settings"); - } - }, [router]); - - useEffect(() => { - let cancelled = false; - void (async () => { - setIntegrationsState("loading"); - setIntegrationsError(null); - - const integrationsResult = await Promise.allSettled([fetchIntegrationsStatus()]); - if (cancelled) return; - - const [result] = integrationsResult; - if (result.status === "fulfilled") { - setIntegrations(result.value); - setIntegrationsState("loaded"); - } else { - setIntegrationsState("error"); - setIntegrationsError( - result.reason instanceof Error - ? result.reason.message - : "Failed to load integration status.", - ); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - async function onDisconnect(provider: ProviderId) { - setActionMessage(null); - setDisconnecting(provider); - try { - const res = await fetch(`/app/api/account/${provider}/disconnect`, { method: "POST" }); - if (!res.ok) throw new Error(await res.text()); - setActionMessage( - provider === "strava" - ? "Strava disconnected. Activity sync is stopped." - : "WHOOP disconnected. Readiness sync is stopped.", - ); - await refreshIntegrations(); - router.refresh(); - } catch (err) { - setActionMessage(err instanceof Error ? err.message : "Failed to disconnect."); - } finally { - setDisconnecting(null); - } - } async function onDeleteAccount() { setActionMessage(null); if (!localDataDeleteAllowed) { setActionMessage( - "Local data reset is disabled by default. Back up your DB, then enable ALLOW_LOCAL_DATA_DELETE only if you really want to delete everything.", + "Local data reset is disabled by default. Back up your database, then enable it only when you want to remove everything.", ); return; } - const ok = window.confirm( - "Reset local app data permanently? This removes your profile, planning history, coaching outputs, and stored connection credentials from this app." + + const confirmed = window.confirm( + "Reset local app data permanently? This removes your profile, plans, coaching outputs, and job history from this app.", ); - if (!ok) return; + if (!confirmed) return; + try { - const res = await fetch("/app/api/account", { method: "DELETE" }); - const payload = await res.json().catch(() => null); - if (!res.ok) { - const detail = payload?.detail ?? payload; - if (res.status === 502 && detail?.code === "auth_delete_failed") { - window.location.assign(detail.redirect_path ?? "/delete?status=deleted&auth_cleanup=pending"); - return; - } - throw new Error(detail?.message ?? detail?.detail ?? JSON.stringify(payload)); + const response = await fetch("/app/api/account", { method: "DELETE" }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(payload?.detail?.message ?? payload?.detail ?? payload?.message ?? "Local data reset failed."); } window.location.assign(payload?.redirect_path ?? "/delete?status=deleted"); - } catch (err) { - setActionMessage(err instanceof Error ? err.message : "Failed to delete this account."); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : "Local data reset failed."); } } @@ -247,135 +42,40 @@ export default function SettingsPage() {
-
-
Connected coaching sources
-

- Strava brings activity history, WHOOP brings readiness context, and the local coach uses whichever evidence is - actually available. +

+
Provider-free coaching
+

+ This release coaches from the profile, goals, races, availability, constraints, and notes you choose to save. + No wearable account or external training-data connection is required or available.

- {integrationsState === "error" ?

{integrationsError}

: null} - {actionMessage ?

{actionMessage}

: null} -
+ -
-
Strava source
-

{providerHeadline("Strava", integrations?.strava)}

-

- {providerSubtext("strava", "Strava", integrations?.strava)} -

-
- {integrations?.strava.operational ? ( - - ) : ( - <> - - {integrations?.strava.linked ? ( - - ) : null} - - )} -
- {integrations?.strava.linked && integrations?.strava.scope ? ( -

Scopes: {integrations.strava.scope}

- ) : null} - {integrations?.strava.linked && integrations?.strava.athlete_id ? ( -

Athlete ID: {integrations.strava.athlete_id}

- ) : null} -

- Setup docs live in docs/local-first/connect-strava.md. -

-
- -
-
WHOOP source
-

{providerHeadline("WHOOP", integrations?.whoop)}

-

- {providerSubtext("whoop", "WHOOP", integrations?.whoop)} -

-
- {integrations?.whoop.operational ? ( - - ) : ( - <> - - {integrations?.whoop.linked ? ( - - ) : null} - - )} -
- {integrations?.whoop.linked && integrations?.whoop.scope ? ( -

Scopes: {integrations.whoop.scope}

- ) : null} -

- Setup docs live in docs/local-first/connect-whoop.md. -

-
- -
+
Local privacy reset
-

- In-app reset removes your local profile, planning history, coaching outputs, jobs, and stored connector - credentials from this app. It is disabled by default in local mode to protect existing plans. +

+ In-app reset removes the local profile, planning history, coaching outputs, and jobs. It is disabled by default + to protect existing plans.

-
- -
+ {actionMessage ?

{actionMessage}

: null} + {!localDataDeleteAllowed ? (

- To enable this destructive action, set both server and web reset flags after backing up your local database. + Back up the local database before enabling both server and web reset flags.

) : null} -
+
); } diff --git a/web/app/src/app/delete/page.tsx b/web/app/src/app/delete/page.tsx index de09891..349f6ed 100644 --- a/web/app/src/app/delete/page.tsx +++ b/web/app/src/app/delete/page.tsx @@ -78,7 +78,8 @@ export default async function DeleteDataPage({ searchParams }: DeleteDataPagePro
  • Athlete profile, competitions, and local owner-scoped app content
  • Active plans, analyses, jobs, daily update runs, weekly recap runs, and coaching outputs
  • Coach threads, messages, events, proposals, and local safety usage rows
  • -
  • Stored Strava and WHOOP credentials, pending OAuth sessions, and connector history
  • +
  • Owner-scoped Head Coach checkpoint payloads used for pause and resume
  • +
  • Legacy encrypted credentials, pending OAuth sessions, and connector history from older development builds
  • Local usage rows that still exist in the database
  • The local technical owner row is preserved in local mode to avoid breaking `LOCAL_OWNER_USER_ID`.

    @@ -86,11 +87,7 @@ export default async function DeleteDataPage({ searchParams }: DeleteDataPagePro

    4. What May Remain

    -

    Database backups, local filesystem exports, logs, screenshots, and external provider records are separate.

    -

    - Disconnecting Strava or WHOOP removes local tokens and attempts provider-side revocation, but provider-held - records remain governed by each provider's own controls and policies. -

    +

    Database backups, local filesystem exports, logs, and screenshots are separate and remain under the local operator's control.

    @@ -104,7 +101,7 @@ export default async function DeleteDataPage({ searchParams }: DeleteDataPagePro

    -

    Last updated: May 31, 2026

    +

    Operational draft — last updated: August 1, 2026

    ); } diff --git a/web/app/src/app/demo/page.tsx b/web/app/src/app/demo/page.tsx index 8018834..0fdbaa0 100644 --- a/web/app/src/app/demo/page.tsx +++ b/web/app/src/app/demo/page.tsx @@ -4,25 +4,31 @@ import Link from "next/link"; import DashboardClient from "@/components/dashboard/dashboard-client"; import PlanViewer from "@/components/plan-viewer/plan-viewer"; import { DEFAULT_DEMO_PERSONA } from "@/lib/demo/demo-data"; +import { DEMO_SEASON_PLAN_BY_PERSONA } from "@/lib/demo/fixtures/v3/season"; +import { DEMO_WEEKLY_PLAN_BY_PERSONA } from "@/lib/demo/fixtures/v3/weekly"; +import { DEFAULT_DEMO_PERSONA_ID } from "@/lib/demo/personas"; import { buildPublicMetadata } from "@/lib/public-metadata"; import type { DashboardStateResponse } from "@/lib/types/dashboard"; export const metadata = buildPublicMetadata({ - title: "paced.coach demo - local-first AI endurance coach", - description: "A sanitized preview of the paced.coach dashboard, season roadmap, training plan, and coach workspace.", + title: "paced.coach demo - no wearable required", + description: + "See how your goals, availability, and constraints become a season roadmap, 28-day plan, and coach chat with your own LLM key. No wearable required.", path: "/demo", }); -const DEMO_NOW_ISO = "2026-03-07T07:45:00.000Z"; +const DEMO_NOW_ISO = "2026-08-04T07:45:00.000Z"; +const DEMO_SEASON_PLAN = DEMO_SEASON_PLAN_BY_PERSONA[DEFAULT_DEMO_PERSONA_ID]; +const DEMO_WEEKLY_PLAN = DEMO_WEEKLY_PLAN_BY_PERSONA[DEFAULT_DEMO_PERSONA_ID]; function demoDashboardState(): DashboardStateResponse { - const todayOverride = DEFAULT_DEMO_PERSONA.weekly.weeks[0]?.days[1] ?? null; + const today = DEMO_WEEKLY_PLAN.weeks[0]?.days[1] ?? null; return { athlete_time: { timezone: "Europe/Berlin", timezone_source: "profile", - today_local_date: todayOverride?.date ?? "2026-03-07", + today_local_date: today?.date ?? "2026-08-04", now_local_iso: DEMO_NOW_ISO, }, analysis: { @@ -32,30 +38,31 @@ function demoDashboardState(): DashboardStateResponse { source_job_id: "demo-analysis-job", }, status_surface: { - kpis: DEFAULT_DEMO_PERSONA.analysis.dashboard_kpis ?? DEFAULT_DEMO_PERSONA.analysis.kpis.slice(0, 6), - source: "analysis", - label: "Demo baseline analysis", + kpis: [], + source: "none", + label: null, updated_at: DEMO_NOW_ISO, - target_date: todayOverride?.date ?? "2026-03-07", + target_date: today?.date ?? "2026-08-04", }, coach_surface: { source: "analysis", scope: "training_block", primary_label: "Coach priority", - primary_text: DEFAULT_DEMO_PERSONA.analysis.coach_action ?? null, + primary_text: + "Keep Saturday's long run easy and fueled. It anchors this block without requiring pace, heart-rate, or readiness targets.", secondary_text: - "The agent keeps load progression explicit, marks recovery gates, and avoids claiming connected readiness signals unless Strava or WHOOP is configured.", + "The agent reasons from the active plan and declared constraints, marks adaptation gates, and makes uncertainty explicit.", updated_at: DEMO_NOW_ISO, }, season: { - season_plan: DEFAULT_DEMO_PERSONA.season, - version: DEFAULT_DEMO_PERSONA.season.version, + season_plan: DEMO_SEASON_PLAN, + version: DEMO_SEASON_PLAN.version, updated_at: DEMO_NOW_ISO, source_job_id: "demo-season-job", }, weekly: { - weekly_plan: DEFAULT_DEMO_PERSONA.weekly, - version: DEFAULT_DEMO_PERSONA.weekly.version, + weekly_plan: DEMO_WEEKLY_PLAN, + version: DEMO_WEEKLY_PLAN.version, updated_at: DEMO_NOW_ISO, source_job_id: "demo-weekly-job", }, @@ -71,7 +78,7 @@ function demoDashboardState(): DashboardStateResponse { has_connected_source: false, next_step: "generated", title: "Your first local plan is ready", - body: "This demo uses declared profile, goals, constraints, and sanitized fixture output. Connected data is optional.", + body: "This demo uses declared profile, goals, constraints, and sanitized fixture output. No external training-data provider is involved.", primary_action: { label: "View plan", href: "#plan" }, secondary_actions: [{ label: "Ask coach", href: "#coach" }], blockers: [], @@ -79,17 +86,16 @@ function demoDashboardState(): DashboardStateResponse { today_mission: { warnings: [ "Demo mode: this is fixture data, not live medical or training advice.", - "Connected readiness, HRV, sleep, and compliance claims stay optional until a user configures providers.", + "No device-derived readiness, sleep, or compliance signal is assumed; describe anything relevant in your own words.", ], - day_override: todayOverride, + day_override: null, }, daily_sync: { - visible: true, - status: "completed", - run_id: "demo-daily-sync", - verdict_preview: - "Keep the session aerobic unless sleep debt or soreness is present. If recovery feels below baseline, cut the final block and keep the skill work.", - sources_used: ["strava", "whoop"], + visible: false, + status: "idle", + run_id: null, + verdict_preview: null, + sources_used: [], proposal_id: null, thread_id: "demo-thread", error_message: null, @@ -98,15 +104,14 @@ function demoDashboardState(): DashboardStateResponse { gate_target: null, }, weekly_recap: { - visible: true, + visible: false, allowed: false, status: "completed_this_window", - thread_id: "demo-thread", + thread_id: null, proposal_id: null, - follow_up_question: "Do you want the next week biased toward trail durability or 10k sharpening?", - summary_preview: - "Load rebuilt conservatively after the reset while threshold markers stayed intact. The next block protects sleep and adds race-specific work only when recovery gates pass.", - pending_action: "follow_up", + follow_up_question: null, + summary_preview: null, + pending_action: "none", can_run: false, attention_message: null, gate_target: null, @@ -128,21 +133,25 @@ function DemoShell({ children }: { children: React.ReactNode }) { function DemoHero() { return (
    - Local-first AI endurance coach + No wearable required
    -

    - A real training system, not a paywalled dashboard. +

    + Your season. Your next 28 days. One coach that knows the plan.

    -

    - paced.coach runs on your machine, generates season roadmaps and 28-day execution blocks, and can use - optional Strava/WHOOP OAuth when you want connected daily sync and weekly recaps. +

    + Start with your goals, training history, availability, and constraints. Bring one supported LLM key. + paced.coach builds the season roadmap, the execution calendar, and the coach conversation from that + declared context. +

    +

    + Runs locally. Version 2.2.0 is provider-free: athlete-declared context is the product, not a fallback.

    {[ - ["Season roadmap", "11 phases", "Macro plan from March to October"], - ["Execution block", "28 days", "Day-level sessions with readiness gates"], - ["Coach workspace", "Context-aware", "Questions, recaps, and plan patches"], - ["Data posture", "Local-first", "No hosted auth or payments required"], + ["Athlete context", "You define it", "Goals, history, availability, and constraints"], + ["Season roadmap", "3 phases", "Macro plan from August to November"], + ["Execution block", "28 days", "Day-level sessions and adaptation gates"], + ["Coach workspace", "Plan-aware", "Questions, reflections, and proposed changes"], ].map(([label, value, body]) => (
    {label}
    @@ -225,7 +234,7 @@ function CoachPreview() {
    {messages.map((message, index) => ( @@ -251,8 +260,8 @@ function DashboardPreview({ dashboardState }: { dashboardState: DashboardStateRe
    @@ -281,8 +290,8 @@ function PlanPreview() { analysis={DEFAULT_DEMO_PERSONA.analysis} nowIso={DEMO_NOW_ISO} publicPreview - seasonPlan={DEFAULT_DEMO_PERSONA.season} - weeklyPlan={DEFAULT_DEMO_PERSONA.weekly} + seasonPlan={DEMO_SEASON_PLAN} + weeklyPlan={DEMO_WEEKLY_PLAN} />
    diff --git a/web/app/src/app/layout.tsx b/web/app/src/app/layout.tsx index f256626..9172141 100644 --- a/web/app/src/app/layout.tsx +++ b/web/app/src/app/layout.tsx @@ -13,11 +13,13 @@ export const viewport: Viewport = { export const metadata = { title: "paced.coach", - description: "Connected endurance coaching for self-coached athletes.", + description: + "Turn your goals, availability, and constraints into a season roadmap, 28-day plan, and coach chat with your own LLM key. No wearable required.", metadataBase: new URL("https://paced.coach"), openGraph: { title: "paced.coach", - description: "Connected endurance coaching for self-coached athletes.", + description: + "Turn your goals, availability, and constraints into a season roadmap, 28-day plan, and coach chat with your own LLM key. No wearable required.", url: "https://paced.coach", siteName: "paced.coach", images: [{ url: "/og.svg", width: 1200, height: 630, alt: "paced.coach" }], @@ -27,7 +29,8 @@ export const metadata = { twitter: { card: "summary_large_image", title: "paced.coach", - description: "Connected endurance coaching for self-coached athletes.", + description: + "Turn your goals, availability, and constraints into a season roadmap, 28-day plan, and coach chat with your own LLM key. No wearable required.", images: ["/og.svg"], }, icons: { diff --git a/web/app/src/app/page.tsx b/web/app/src/app/page.tsx index 48e7145..cb523f0 100644 --- a/web/app/src/app/page.tsx +++ b/web/app/src/app/page.tsx @@ -3,8 +3,9 @@ import { redirect } from "next/navigation"; import { buildPublicMetadata } from "@/lib/public-metadata"; export const metadata = buildPublicMetadata({ - title: "paced.coach - local-first AI endurance coach", - description: "Run paced.coach locally for your own training plans, coach history, and optional connected data.", + title: "paced.coach - a complete AI endurance coach", + description: + "Turn your goals, availability, and constraints into a season roadmap, 28-day plan, and coach chat with your own LLM key. No wearable required.", path: "/", }); diff --git a/web/app/src/app/privacy/page.tsx b/web/app/src/app/privacy/page.tsx index d814e9e..9577639 100644 --- a/web/app/src/app/privacy/page.tsx +++ b/web/app/src/app/privacy/page.tsx @@ -12,9 +12,12 @@ export default function PrivacyPage() { return (
    -

    1. Data Controller

    -

    Leon Zajchowski, operating under the business name paced.coach

    -

    Petersauer Strasse 34, 68307 Mannheim, Germany

    +

    1. Local Operator and Project Contact

    +

    + The person who installs and operates this open-source app controls its local database and configuration. The + project maintainer does not receive that local data merely because the software is installed. +

    +

    Project contact: Leon Zajchowski, paced.coach, Petersauer Strasse 34, 68307 Mannheim, Germany

    Contact: support@paced.coach

    @@ -25,8 +28,11 @@ export default function PrivacyPage() {
    • Local owner compatibility fields used by the single-user app
    • Profile data, athlete context, goals, races, constraints, and training preferences
    • -
    • Optional Strava and WHOOP data that you authorize the local app to access
    • -
    • Analysis, plan, recap, and coaching outputs generated in the app
    • +
    • Analysis, plan, and coaching outputs generated in the app
    • +
    • + Local Head Coach checkpoints containing resumable working context, plan drafts, model messages, tool + results, and clarification state +
    • Technical logs created by your local runtime
    • Support communications if you choose to contact the maintainer
    @@ -35,10 +41,10 @@ export default function PrivacyPage() {

    3. Purposes and Legal Bases

      -
    • Running the local app, connected training workflows, and coaching outputs (GDPR Art. 6(1)(b))
    • +
    • Running the local app and creating coaching outputs under the local operator's chosen legal basis
    • Security, stability, and local safety controls (GDPR Art. 6(1)(f))
    • Compliance with legal obligations where applicable (GDPR Art. 6(1)(c))
    • -
    • Processing health-related training data based on consent where required (GDPR Art. 9(2)(a))
    • +
    • Health-related training data requires an applicable GDPR Art. 9 condition where the GDPR applies
    @@ -46,7 +52,7 @@ export default function PrivacyPage() {

    4. Recipients and Processors

    In a local-first open-source build, processor use depends on how you configure and run the software. The - following categories may apply when you enable the related integrations: + following categories may apply when you configure the related services:

    • @@ -58,46 +64,39 @@ export default function PrivacyPage() { hosted login.
    • - AI inference: the LLM provider configured by the operator. The current default path uses an - OpenAI-compatible API key; other configured model providers may receive prompt context when used. + AI inference: OpenAI receives the prompt context required for plan generation and coaching.
    • Optional observability: LangSmith only if LANGSMITH_API_KEY is configured. Traces may contain prompt and response content.
    • -
    • - Connected training providers: Strava and WHOOP, if you configure OAuth and approve the - provider consent flow. OAuth tokens are encrypted locally with FERNET_KEY and should not be - logged. -

    We do not sell personal data.

    -

    5. AI and Connected Device Data Transparency

    +

    5. AI Transparency

    - If you connect Strava or WHOOP, imported activity, recovery, and related fitness data may be processed by AI - systems to generate workout analysis, a season roadmap, a 28-day plan, daily coaching, and related summaries. + Athlete-provided profile, goal, race, constraint, plan, and coach-chat context may be sent to OpenAI to + generate analysis, a season roadmap, a 28-day plan, and coaching responses.

    - Outputs may combine connected-provider data, user-provided context, plan history, and AI-generated reasoning. - Connected-provider data remains one of the underlying sources used to prepare those outputs. + Version 2.2.0 does not connect to external activity or recovery-data providers. It must not present missing + device evidence as known fact.

    The app is intended to provide coaching support and training guidance. It is not intended to make fully automated decisions with legal or similarly significant effects.

    - You can stop future source imports by disconnecting the provider. Local stored data can be removed with the - local privacy reset after you intentionally enable the reset flag. + Local stored data can be removed with the local privacy reset after you intentionally enable the reset flag.

    6. International Transfers

    - External AI providers, Strava, WHOOP, or optional observability providers may operate outside the EU/EEA. + External AI providers or optional observability providers may operate outside the EU/EEA. Operators should review transfer safeguards before any hosted or production use.

    @@ -107,8 +106,13 @@ export default function PrivacyPage() {
    • Local content data remains in your local Postgres database until you delete or reset it.
    • - In local mode, the privacy reset removes user-scoped app data and connector tokens while preserving the - technical local owner row. + Checkpoints for completed, failed, or cancelled Head Coach runs remain in local Postgres for seven days by + default, then scheduled cleanup removes them. In-progress and awaiting-input checkpoints remain available + so the run can resume. +
    • +
    • + In local mode, the privacy reset removes user-scoped app data, owner-scoped checkpoint payloads, and legacy + connector rows while preserving the technical local owner row.
    • Legal retention exceptions may apply where required by law.
    • Backup copies may continue to contain historical snapshots until overwritten by the operator.
    • @@ -150,7 +154,7 @@ export default function PrivacyPage() {

    -

    Last updated: May 31, 2026

    +

    Operational draft — last updated: August 1, 2026

    ); } diff --git a/web/app/src/app/strava/complete/page.tsx b/web/app/src/app/strava/complete/page.tsx deleted file mode 100644 index 32137f8..0000000 --- a/web/app/src/app/strava/complete/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import StravaCompleteClient from "./strava-complete-client"; - -type PageProps = { - searchParams?: Promise>; -}; - -function getSingleValue(value: string | string[] | undefined): string | null { - if (typeof value === "string") { - return value; - } - if (Array.isArray(value) && typeof value[0] === "string") { - return value[0]; - } - return null; -} - -export default async function StravaCompletePage({ searchParams }: PageProps) { - const resolvedSearchParams = (await searchParams) ?? {}; - return ( - - ); -} diff --git a/web/app/src/app/strava/complete/strava-complete-client.tsx b/web/app/src/app/strava/complete/strava-complete-client.tsx deleted file mode 100644 index cbe5ff0..0000000 --- a/web/app/src/app/strava/complete/strava-complete-client.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; - -type Props = { - connected: string | null; - oauthError: string | null; -}; - -function resolveTargetPath({ connected, oauthError }: Props): string { - if (connected === "strava") { - return "/app/settings?connected=strava"; - } - if (oauthError === "strava") { - return "/app/settings?oauth_error=strava"; - } - return "/app/settings"; -} - -function resolveCopy({ connected }: Props): { title: string; body: string } { - if (connected === "strava") { - return { - title: "Finishing Strava connection", - body: "We are taking you back to Settings.", - }; - } - return { - title: "Strava connection needs attention", - body: "We are sending you back to Settings so you can retry the reconnect flow.", - }; -} - -export default function StravaCompleteClient(props: Props) { - const router = useRouter(); - const targetPath = resolveTargetPath(props); - const copy = resolveCopy(props); - - useEffect(() => { - router.replace(targetPath); - }, [router, targetPath]); - - return ( -
    -
    -

    {copy.title}

    -

    {copy.body}

    -
    - - Continue - -
    -
    -
    - ); -} diff --git a/web/app/src/app/terms/page.tsx b/web/app/src/app/terms/page.tsx index 1af7054..7b1c0f9 100644 --- a/web/app/src/app/terms/page.tsx +++ b/web/app/src/app/terms/page.tsx @@ -23,11 +23,10 @@ export default function TermsPage() {

    2. Service Description

    - paced.coach provides software for endurance training context management, plan generation, coaching chat, and - optional connected-data workflows. + paced.coach provides software for endurance training context management, plan generation, and coaching chat.

    The app does not provide medical, therapeutic, or diagnostic advice.

    -

    Outputs depend on the data you provide, the integrations you configure, and third-party provider availability.

    +

    Outputs depend on the context you declare and the language-model service you configure.

    @@ -79,7 +78,7 @@ export default function TermsPage() {

    8. Third-Party Providers and Changes

    -

    The app may depend on configured LLM providers, Strava, WHOOP, LangSmith, and local infrastructure.

    +

    The app may depend on the configured LLM provider, optional LangSmith tracing, and local infrastructure.

    Outages, rate limits, API changes, or account restrictions at those providers may affect data freshness or feature availability. @@ -91,9 +90,9 @@ export default function TermsPage() {

    9. Local Data Reset

    Local data reset is available from Settings only after the operator enables the explicit reset flags. The - reset removes user-scoped app data and connector credentials from the local application database. + reset removes user-scoped app data and any legacy connector records from the local application database.

    -

    Disconnect Strava or WHOOP to stop future local imports from those providers.

    +

    Version 2.2.0 does not perform external training-data imports.

    @@ -102,7 +101,9 @@ export default function TermsPage() {

    Mandatory consumer protection provisions of your country of residence remain unaffected.

    -

    Last updated: May 31, 2026

    +

    + Operational draft for external legal review — not legal advice. Last updated: August 1, 2026. +

    ); } diff --git a/web/app/src/app/whoop/complete/page.tsx b/web/app/src/app/whoop/complete/page.tsx deleted file mode 100644 index e73f9e6..0000000 --- a/web/app/src/app/whoop/complete/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import WhoopCompleteClient from "./whoop-complete-client"; - -type PageProps = { - searchParams?: Promise>; -}; - -function getSingleValue(value: string | string[] | undefined): string | null { - if (typeof value === "string") { - return value; - } - if (Array.isArray(value) && typeof value[0] === "string") { - return value[0]; - } - return null; -} - -export default async function WhoopCompletePage({ searchParams }: PageProps) { - const resolvedSearchParams = (await searchParams) ?? {}; - return ( - - ); -} diff --git a/web/app/src/app/whoop/complete/whoop-complete-client.tsx b/web/app/src/app/whoop/complete/whoop-complete-client.tsx deleted file mode 100644 index e2f0be1..0000000 --- a/web/app/src/app/whoop/complete/whoop-complete-client.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; - -type Props = { - connected: string | null; - oauthError: string | null; -}; - -function resolveTargetPath({ connected, oauthError }: Props): string { - if (connected === "whoop") { - return "/app/settings?connected=whoop"; - } - if (oauthError === "whoop") { - return "/app/settings?oauth_error=whoop"; - } - return "/app/settings"; -} - -function resolveCopy({ connected }: Props): { title: string; body: string } { - if (connected === "whoop") { - return { - title: "Finishing WHOOP connection", - body: "We are taking you back to Settings.", - }; - } - return { - title: "WHOOP connection needs attention", - body: "We are sending you back to Settings so you can retry the reconnect flow.", - }; -} - -export default function WhoopCompleteClient(props: Props) { - const router = useRouter(); - const targetPath = resolveTargetPath(props); - const copy = resolveCopy(props); - - useEffect(() => { - router.replace(targetPath); - }, [router, targetPath]); - - return ( -
    -
    -

    {copy.title}

    -

    {copy.body}

    -
    - - Continue - -
    -
    -
    - ); -} diff --git a/web/app/src/components/app-nav.tsx b/web/app/src/components/app-nav.tsx index 36c92e5..69b6519 100644 --- a/web/app/src/components/app-nav.tsx +++ b/web/app/src/components/app-nav.tsx @@ -56,9 +56,6 @@ export default function AppNav({ variant }: { variant?: "desktop-rail" | "mobile can_send_message: false, coach_gate_message: null, coach_gate_target: null, - can_trigger_recap: false, - training_provider_message: null, - recap_gate_target: null, week_anchor_utc: "", }), })); diff --git a/web/app/src/components/coach-chat/coach-inbox-composer-bar.tsx b/web/app/src/components/coach-chat/coach-inbox-composer-bar.tsx index 07e0a97..d611fca 100644 --- a/web/app/src/components/coach-chat/coach-inbox-composer-bar.tsx +++ b/web/app/src/components/coach-chat/coach-inbox-composer-bar.tsx @@ -1,5 +1,3 @@ -import Link from "next/link"; - import type { CoachQuota } from "@/lib/types/quota"; import { @@ -19,16 +17,12 @@ type CoachInboxComposerBarProps = { coachGateTarget: "settings" | null; isConversationView: boolean; selectedThreadStatus: "active" | "archived"; - threadCanTriggerRecap: boolean; - trainingProviderMessage: string | null; - recapGateTarget: "settings" | null; busyAction: BusyAction | null; composerDisabled: boolean; listComposerDisabled: boolean; input: string; onInputChange: (value: string) => void; onSendMessage: (forceNewThread?: boolean) => void; - onTriggerRecap: () => void; onQuickPromptSelect: (value: string) => void; }; @@ -41,23 +35,16 @@ export default function CoachInboxComposerBar({ coachGateTarget, isConversationView, selectedThreadStatus, - threadCanTriggerRecap, - trainingProviderMessage, busyAction, composerDisabled, listComposerDisabled, input, onInputChange, onSendMessage, - onTriggerRecap, onQuickPromptSelect, }: CoachInboxComposerBarProps) { const composerLocked = isConversationView ? composerDisabled : listComposerDisabled; - const coachGateHref = "/app/settings"; - const coachGateLabel = "Open integration settings"; - const blockedSendLabel = coachGateTarget === "settings" ? "Connect Data Source" : "Try Tomorrow"; - const gateHref = "/app/settings"; - const gateLabel = "Open integration settings"; + const blockedSendLabel = coachGateTarget === "settings" ? "Coach unavailable" : "Try Tomorrow"; return (
    @@ -75,25 +62,6 @@ export default function CoachInboxComposerBar({ {coachGateMessage ? (
    {coachGateMessage}
    -
    - Connect Strava or WHOOP in Settings before starting coach chat. -
    - - {coachGateLabel} - -
    - ) : null} - {trainingProviderMessage && trainingProviderMessage !== coachGateMessage ? ( -
    -
    {trainingProviderMessage}
    -
    - {threadCanTriggerRecap - ? "Coach chat still works, and Weekly Recap can still run from your other active source." - : "Coach chat still works, but Weekly Recap needs a connected training source."} -
    - - {gateLabel} -
    ) : null} @@ -121,15 +89,6 @@ export default function CoachInboxComposerBar({
    Enter sends · Shift+Enter new line
    -
    ); diff --git a/web/app/src/components/dashboard/daily-sync-widget.tsx b/web/app/src/components/dashboard/daily-sync-widget.tsx deleted file mode 100644 index b5ab361..0000000 --- a/web/app/src/components/dashboard/daily-sync-widget.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { CheckCircle2, RefreshCw, Zap } from "lucide-react"; -import { useState, useTransition } from "react"; - -import type { DailyRunResponse, DashboardDailySyncState } from "@/lib/types/dashboard"; -import { formatDailySyncSources } from "@/lib/types/dashboard"; -import { openCoachThread } from "@/lib/types/ask-about"; - -type Props = { - state: DashboardDailySyncState; - onRun: (athleteCheckIn?: string) => Promise; -}; - -const CHECK_IN_MAX_LENGTH = 800; - -function reviewHref(threadId: string | null): string { - if (!threadId) return "/app/coach"; - return `/app/coach?thread_id=${encodeURIComponent(threadId)}`; -} - -export default function DailySyncWidget({ state, onRun }: Props) { - const [athleteCheckIn, setAthleteCheckIn] = useState(""); - const [isPending, startTransition] = useTransition(); - const sourcesUsedLabel = formatDailySyncSources(state.sources_used); - const showAttention = Boolean(state.attention_message); - const gateHref = "/app/settings"; - const gateLabel = "Open integration settings"; - const needsFirstConnection = state.attention_message?.startsWith("No training data source connected") ?? false; - const blockedButtonLabel = needsFirstConnection ? "Connect Source To Sync" : "Reconnect To Sync"; - - if (!state.visible) { - return null; - } - - const handleSync = () => { - if (!state.can_run) { - return; - } - startTransition(async () => { - const response = await onRun(athleteCheckIn.trim() || undefined); - if (response?.proposal_id && response.thread_id) { - openCoachThread(response.thread_id); - } - if (response) { - setAthleteCheckIn(""); - } - }); - }; - - if (state.status === "completed") { - return ( -
    -
    -
    - -

    Synced

    - {sourcesUsedLabel ? via {sourcesUsedLabel} : null} -
    - {state.proposal_id ? ( - - Review in Coach - - ) : null} -
    - {state.verdict_preview ? ( -

    {state.verdict_preview}

    - ) : null} -
    - ); - } - - return ( -
    -
    -
    -

    - Ready for today's training? -

    -

    - Run your daily sync to check latest recovery metrics and get today's tactical adjustments. -

    - -