diff --git a/.env.example b/.env.example index 8f55bbc..dfee045 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,13 @@ SUPABASE_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Project Settings -> API -> JWT Secret (local: see `supabase status`) SUPABASE_JWT_SECRET=your-jwt-secret -LLM_PROVIDER=anthropic -LLM_MODEL=claude-sonnet-4-20250514 -ANTHROPIC_API_KEY=sk-ant-... +# Active default is groq (free tier). The anthropic path in +# langgraph_agent.py is commented out until a paid key exists — setting +# LLM_PROVIDER=anthropic today just logs a notice and falls back to groq. +LLM_PROVIDER=groq +LLM_MODEL=llama-3.3-70b-versatile +GROQ_API_KEY=gsk_... +# ANTHROPIC_API_KEY=sk-ant-... DAILY_COST_CAP_USD=10.0 ADMIN_USER_IDS=your-admin-user-id # --- Notification Service Settings --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08d8cd3..a1f63a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ concurrency: jobs: lint-and-test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -34,5 +34,52 @@ jobs: run: ruff check src/ tests/ - name: Type check run: mypy src/ --ignore-missing-imports - - name: Test - run: pytest tests/ -v --tb=short + # Fast gates above run without a database. The full suite needs a live + # local Supabase (see tests/conftest.py) — without it, every store-backed + # test self-skips and CI silently shrinks to the unit tests only. + - uses: supabase/setup-cli@v1 + with: + version: latest + - name: Start local Supabase + run: supabase start + - name: Apply migrations + run: supabase db reset + # The migrations contain zero GRANT statements — hosted Supabase's + # default privileges cover that in prod, a raw local stack does not. + # Grant here (CI-only) so service_role/anon/authenticated behave like + # they do on the hosted project. + - name: Grant role privileges (parity with hosted defaults) + run: | + DB_URL=$(supabase status -o env | grep '^DB_URL=' | cut -d= -f2- | tr -d '"') + psql "$DB_URL" -v ON_ERROR_STOP=1 \ + -c "GRANT USAGE ON SCHEMA public TO service_role, anon, authenticated;" \ + -c "GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role;" \ + -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO service_role;" \ + -c "GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon, authenticated;" + - name: Export Supabase env for tests + run: | + supabase status -o env \ + | grep -E '^(API_URL|ANON_KEY|SERVICE_ROLE_KEY|JWT_SECRET)=' > sb.env + . ./sb.env + { + echo "SUPABASE_URL=$API_URL" + echo "SUPABASE_KEY=$ANON_KEY" + echo "SUPABASE_SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY" + echo "SUPABASE_JWT_SECRET=$JWT_SECRET" + } >> "$GITHUB_ENV" + rm sb.env + - name: Preflight — fail HERE with the real error if the store can't connect + run: python -c "from lpi import store; print('goals reachable:', store.list_goals() is not None)" + - name: Test (full suite — fails if store-backed tests were skipped) + run: | + pytest tests/ -v --tb=short --junitxml=pytest-report.xml + python - <<'PY' + import xml.etree.ElementTree as ET + s = ET.parse("pytest-report.xml").getroot().find("testsuite") + skipped, total = int(s.get("skipped")), int(s.get("tests")) + print(f"{total} collected, {skipped} skipped") + # Guard against the pre-Jul-2026 failure mode: Supabase down -> + # 167/188 tests skip -> CI still green. A handful of legitimate + # skips is fine; a majority means the DB never came up. + assert skipped < total * 0.2, "Most tests skipped — Supabase not reachable in CI" + PY diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cf476f5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# LPI Platform — Claude Code Project Guide + +FastAPI backend for the Life Programmable Interface: goal registry, activity +signals, LLM-assisted recommendations. SMILE methodology (6 phases). Part of +the Life Atlas / WINNIIO platform. + +## Commands + +```bash +pip install -e ".[dev]" # one-time setup +ruff check src/ tests/ # lint (must be clean before every push) +mypy src/ --ignore-missing-imports # typecheck (must be clean) +pytest tests/ -v # full suite — needs local Supabase (below) +uvicorn lpi.main:app --reload --port 8000 # run locally +``` + +## Tests need a local Supabase + +`supabase start` in the repo root (Docker required), then put the values from +`supabase status` into `.env` (`SUPABASE_URL`, `SUPABASE_KEY`, +`SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_JWT_SECRET`). **Without it, all +store-backed tests self-skip** — `28 passed, 167 skipped` means Supabase is +down, NOT that the suite is green. Tests marked `@pytest.mark.unit` always run. + +## Architecture (read docs/ARCHITECTURE.md before claiming anything) + +``` +Client → FastAPI (src/lpi) → Supabase (Postgres + Auth + RLS) + └→ LangGraph agent → Groq LLM (recommendations) +``` + +- `routers/` — HTTP endpoints; `store.py` — ALL Supabase access; `middleware/` + — JWT auth + per-IP rate limit; `langgraph_agent.py` — LLM reasoning; + `cost_guard.py` — daily LLM spend cap. + +## Gotchas (learned the hard way) + +1. **Never trust migration files over the live schema** — verify with + `supabase db query` before touching a table. +2. Ownership checks return **404, not 403** (don't leak resource existence). +3. `store.py` is the only file allowed to touch Supabase. No client creation + in routers. +4. LLM calls must stay non-fatal: `_call_llm` returns `None` on any failure + and the deterministic engine takes over. Never let an LLM error 500 an + endpoint. +5. Rate limiter + cost guard are in-memory/per-process — known limitation, + don't "fix" by adding a DB write per request without discussing. +6. Branch off `staging`, PR to `staging`. `main` is release-only. +7. Lint + typecheck locally before every push (CI minutes are budgeted). +8. Never commit tokens — even local/expired ones. `detect-secrets` runs in + pre-commit; install it: `pre-commit install`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8d3d945 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# LPI Platform — Architecture (one page) + +_Last verified against code: 2026-07-05. If this drifts from `src/`, the code +wins — update this file in the same PR._ + +## Data flow + +``` +Browser / API client + │ Authorization: Bearer + ▼ +FastAPI app (src/lpi/main.py) + │ + ├─ middleware/rate_limit.py fixed-window per client IP (in-memory) + ├─ middleware/auth.py JWT verify: HS256 (shared secret) or + │ ES256/RS256 via project JWKS + ▼ +routers/ goals · signals · recommendations · users · me · metrics · + github_auth · webhooks + │ (ownership checks here: caller's user_id or admin, 404 on miss) + ▼ +store.py — the ONLY module that talks to Supabase (service-role client, + RLS bypassed server-side by design; every query user-scoped) + ▼ +Supabase Postgres — tables: goals, activity_signals, recommendations, + recommendation_feedback, goal_phase_transitions, system_logs, users, + notifications (RLS enabled via supabase/migrations) + +Recommendations path additionally: +routers/recommendations → agent_pipeline / langgraph_agent + → cost_guard.check_budget() (daily USD cap, refuses when hit) + → Groq LLM (default) — non-fatal: any failure returns None + → fallback: deterministic recommendation_engine.py +``` + +## External integrations + +- **GitHub OAuth + webhooks** (`routers/github_auth.py`, `routers/webhooks.py`) + — activity signals from commits/PRs. +- **ZeroClaw** (`utils/zeroclaw_*`) — HMAC-authenticated security-scan webhook. +- **SMTP notifications** (`notifications.py`) — goal lifecycle emails. + +## Deploy + +Docker + Traefik (TLS) on a VM → `lpi-backend.lifeatlas.online`, port 8020. +`docker-compose.yml` in repo root. CI: `.github/workflows/ci.yml` +(ruff → mypy → full pytest against a local Supabase started in the job). + +## Known limitations (intentional, don't "discover" them) + +- Rate limit + LLM cost cap are per-process, in-memory — single-instance + assumptions; move to shared store before scaling out. +- SMILE phase logic lives in `smile.py`; the 6 phases are canonical + (reality-emulation → perpetual-wisdom). Do not reintroduce the old + hallucinated 5-phase names. diff --git a/pyproject.toml b/pyproject.toml index a72a270..8e812c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ ignore = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "unit: pure unit test — no Supabase required, always runs (bypasses the clear_store skip)", +] filterwarnings = [ "once::DeprecationWarning", "ignore:.*timeout.*:DeprecationWarning", diff --git a/scripts/test_endpoints.py b/scripts/test_endpoints.py index e524ff8..8ba049e 100644 --- a/scripts/test_endpoints.py +++ b/scripts/test_endpoints.py @@ -1,3 +1,4 @@ +import os import random import uuid @@ -7,8 +8,13 @@ # Point this to the local ingest endpoint (e.g., "http://localhost:8000/api/v1/signals/") API_INGEST_URL = "http://localhost:8001/api/v1/signals/" -# Enter the required JWT or dummy token to pass the auth middleware -AUTH_TOKEN = "eyJhbGciOiJFUzI1NiIsImtpZCI6ImI4MTI2OWYxLTIxZDgtNGYyZS1iNzE5LWMyMjQwYTg0MGQ5MCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU0MzIxL2F1dGgvdjEiLCJzdWIiOiI5MzY0ZjRiMS00NDc4LTQ4MjAtYjMwOC0wOGY5YmM4YWRhZTAiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzgxNjY2NzU3LCJpYXQiOjE3ODE2NjMxNTcsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWxfdmVyaWZpZWQiOnRydWV9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6InBhc3N3b3JkIiwidGltZXN0YW1wIjoxNzgxNjYzMTU3fV0sInNlc3Npb25faWQiOiIzMmFjYWY1Zi04OGMyLTRhMzEtOWRkNi1lZTg3YjNlMWY1Y2IiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.c9hH6QP0_VBKiuZA7SEn4lMQikWSFYQQmN1fD3ad3GkzhcP9zcXZX45qdW7MFZ7fPAdlYkgG1k6lVnygO3lyTw" +# JWT for the auth middleware — NEVER hardcode a token here (even a local +# one trains bad habits and trips secret scanners). Export it instead: +# PowerShell: $env:LPI_TEST_JWT = "" +# bash: export LPI_TEST_JWT= +AUTH_TOKEN = os.environ.get("LPI_TEST_JWT", "") +if not AUTH_TOKEN: + raise SystemExit("Set LPI_TEST_JWT env var before running this script.") HEADERS = {"Content-Type": "application/json", "Authorization": f"Bearer {AUTH_TOKEN}"} diff --git a/src/lpi/cost_guard.py b/src/lpi/cost_guard.py new file mode 100644 index 0000000..36bdb85 --- /dev/null +++ b/src/lpi/cost_guard.py @@ -0,0 +1,104 @@ +"""Daily LLM cost guard — enforces settings.daily_cost_cap_usd. + +WHAT: a process-local daily spend tracker consulted before every LLM call +and updated after every successful one. When the day's estimated spend +reaches the cap, further calls are refused and the recommendations flow +falls back to the deterministic engine (same graceful path as an LLM +outage — see langgraph_agent._call_llm). + +WHY: DAILY_COST_CAP_USD existed in config but nothing enforced it — the +gap was flagged in recommendation_engine.py. Groq's free tier makes this +academic today; the moment a paid Anthropic key lands it is not. + +HOW: costs are estimated from the provider's reported token usage when +available, otherwise from a conservative flat estimate. Pricing is a +static table — deliberately conservative rather than precise; the point +is a hard ceiling, not accounting. + +LIMITATION: in-memory and per-process, same trade-off as the fixed-window +rate limiter in middleware/rate_limit.py. It resets on restart and is not +shared across replicas. Good enough for a single-instance deploy; move to +a shared store (Supabase table / Redis) before scaling out. +""" + +import logging +import threading +from datetime import UTC, date, datetime + +from lpi.config import settings + +logger = logging.getLogger(__name__) + +# Per-million-token prices (input_usd, output_usd). Conservative estimates — +# rounded UP so the guard trips early rather than late. +_PRICING_PER_MTOK: dict[str, tuple[float, float]] = { + "groq": (0.60, 0.80), # llama-3.3-70b-versatile tier + "anthropic": (3.00, 15.00), # claude sonnet tier +} +# Used when the provider response carries no usage data: assume a full +# prompt (~2000 tokens) and the max_tokens=1000 completion actually used. +_FALLBACK_TOKENS = (2000, 1000) + +_lock = threading.Lock() +_day: date | None = None +_spent_usd: float = 0.0 + + +def _today() -> date: + return datetime.now(UTC).date() + + +def _roll_day_locked() -> None: + """Reset the counter when the UTC day changes. Caller must hold _lock.""" + global _day, _spent_usd + today = _today() + if _day != today: + _day = today + _spent_usd = 0.0 + + +def check_budget() -> bool: + """Return True if another LLM call is allowed under the daily cap.""" + with _lock: + _roll_day_locked() + allowed = _spent_usd < settings.daily_cost_cap_usd + if not allowed: + logger.warning( + "Daily LLM cost cap reached (%.2f/%.2f USD) — refusing LLM call, " + "deterministic fallback will be used.", + _spent_usd, + settings.daily_cost_cap_usd, + ) + return allowed + + +def record_usage( + provider: str, + input_tokens: int | None = None, + output_tokens: int | None = None, +) -> float: + """Record one call's estimated cost. Returns the USD amount recorded.""" + in_price, out_price = _PRICING_PER_MTOK.get(provider, _PRICING_PER_MTOK["anthropic"]) + in_tok = input_tokens if input_tokens is not None else _FALLBACK_TOKENS[0] + out_tok = output_tokens if output_tokens is not None else _FALLBACK_TOKENS[1] + cost = (in_tok / 1_000_000) * in_price + (out_tok / 1_000_000) * out_price + with _lock: + _roll_day_locked() + global _spent_usd + _spent_usd += cost + return cost + + +def spent_today() -> float: + """Current UTC day's estimated spend in USD.""" + with _lock: + _roll_day_locked() + return _spent_usd + + +def reset_for_tests() -> None: + """Zero the counter. Call ONLY from test fixtures.""" + global _day, _spent_usd + with _lock: + _day = None + _spent_usd = 0.0 diff --git a/src/lpi/langgraph_agent.py b/src/lpi/langgraph_agent.py index 4fd4e7a..b5a51b5 100644 --- a/src/lpi/langgraph_agent.py +++ b/src/lpi/langgraph_agent.py @@ -56,6 +56,7 @@ import logging from typing import TypedDict +from lpi import cost_guard from lpi.config import settings from lpi.models import Goal, Signal @@ -180,6 +181,9 @@ def _call_llm(prompt: str) -> str | None: """ provider = (settings.llm_provider or "groq").lower().strip() + if not cost_guard.check_budget(): + return None + if provider == "anthropic": # ════════════════════════════════════════════════════════════════════ # ANTHROPIC (Claude) — KEPT HERE, COMMENTED OUT. @@ -223,6 +227,12 @@ def _call_llm(prompt: str) -> str | None: max_tokens=1000, messages=[{"role": "user", "content": prompt}], ) + usage = getattr(response, "usage", None) + cost_guard.record_usage( + "groq", + input_tokens=getattr(usage, "prompt_tokens", None), + output_tokens=getattr(usage, "completion_tokens", None), + ) return response.choices[0].message.content except Exception: logger.exception("Groq LangGraph LLM reasoning call failed.") diff --git a/src/lpi/routers/github_auth.py b/src/lpi/routers/github_auth.py index 623d1c7..37b02be 100644 --- a/src/lpi/routers/github_auth.py +++ b/src/lpi/routers/github_auth.py @@ -1,3 +1,4 @@ +import logging import os import uuid from datetime import UTC, datetime @@ -11,6 +12,8 @@ from lpi.middleware.auth import get_current_user from lpi.models import Signal, SignalCreate +logger = logging.getLogger(__name__) + load_dotenv() router = APIRouter() @@ -170,7 +173,11 @@ async def auto_register_webhook(request: TrackRepoRequest): is_success = response.status_code in [200, 201, 422] if not is_success: - print(f"⚠️ Webhook registration returned status {response.status_code} (likely no admin rights). Proceeding with historical sync.") + logger.warning( + "Webhook registration returned status %s (likely no admin rights). " + "Proceeding with historical sync.", + response.status_code, + ) repo_db[f"{request.repo_owner}/{request.repo_name}"] = request.user_id @@ -217,7 +224,7 @@ async def auto_register_webhook(request: TrackRepoRequest): target_goal_id = g.id break except Exception as e: - print(f"Goal lookup failed during tracking: {e}") + logger.exception("Goal lookup failed during tracking: %s", e) # Deduplicate: check if this event was already ingested github_event_id = event.get("id") @@ -247,7 +254,7 @@ async def auto_register_webhook(request: TrackRepoRequest): store.insert_signal(new_signal) ingested_count += 1 except Exception as e: - print(f"Failed to fetch history for tracked repo: {e}") + logger.exception("Failed to fetch history for tracked repo: %s", e) return { "status": "success", @@ -293,7 +300,11 @@ async def disconnect_github(request: DisconnectRepoRequest): delete_url = f"{hooks_url}/{target_hook_id}" await client.delete(delete_url, headers=headers) except Exception as e: - print(f"⚠️ Webhook deletion from GitHub failed (likely rate-limited), proceeding with local database cleanup: {e}") + logger.warning( + "Webhook deletion from GitHub failed (likely rate-limited), " + "proceeding with local database cleanup: %s", + e, + ) # Step 3: Remove the token and repo mapping from our local mock DB if request.user_id in token_db: @@ -312,7 +323,7 @@ async def disconnect_github(request: DisconnectRepoRequest): # 3. Clean up any historical webhook test signals with "github_api" source containing the repo name store._get_client().table("activity_signals").delete().eq("user_id", request.user_id).eq("source", "github_api").filter("payload->>repo", "eq", repo_full_name).execute() except Exception as e: - print(f"Failed to clean up signals on repo disconnect: {e}") + logger.exception("Failed to clean up signals on repo disconnect: %s", e) return {"status": "success", "message": f"Successfully disconnected from {request.repo_name}."} diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index d883171..7dcc0e0 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -57,6 +57,7 @@ no try/except is needed at this call site. """ +import logging import uuid from datetime import UTC, datetime @@ -68,7 +69,9 @@ from lpi.middleware.auth import UserContext, get_current_user, get_current_user_context from lpi.models import Signal, SignalCreate from lpi.notifications import create_notification_if_new -from lpi.utils.logging import log_user_activity, logger +from lpi.utils.logging import log_user_activity + +logger = logging.getLogger(__name__) router = APIRouter() @@ -183,7 +186,7 @@ def ingest_signal( }, ) - print(new_signal.model_dump()) + logger.debug("Signal created: %s", new_signal.model_dump()) return new_signal diff --git a/src/lpi/routers/users.py b/src/lpi/routers/users.py index 3a91467..3017f85 100644 --- a/src/lpi/routers/users.py +++ b/src/lpi/routers/users.py @@ -1,8 +1,12 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, status from lpi import store from lpi.middleware.auth import UserContext, get_current_user_context +logger = logging.getLogger(__name__) + router = APIRouter() @@ -27,6 +31,6 @@ def get_users_map(user_context: UserContext = Depends(get_current_user_context)) else "", } return users_map - except Exception as e: - print(f"Error fetching users: {e}") + except Exception: + logger.exception("Error fetching users") return {} diff --git a/src/lpi/routers/webhooks.py b/src/lpi/routers/webhooks.py index b1a9a26..23fa3d1 100644 --- a/src/lpi/routers/webhooks.py +++ b/src/lpi/routers/webhooks.py @@ -1,3 +1,4 @@ +import logging import uuid from datetime import UTC, datetime from typing import Any @@ -9,6 +10,8 @@ from lpi.notifications import create_notification_if_new from lpi.routers.github_auth import repo_db +logger = logging.getLogger(__name__) + router = APIRouter() @@ -68,7 +71,7 @@ async def github_webhook_receiver(request: Request): user_id = repo_db.get(repo_full_name) if repo_full_name else None if not user_id: - print(f"⚠️ Webhook received for unregistered repo '{repo_full_name}', skipping save.") + logger.warning("Webhook received for unregistered repo %r, skipping save.", repo_full_name) return {"status": "success"} # Auto-detect matching goal linked to this repository @@ -80,7 +83,7 @@ async def github_webhook_receiver(request: Request): target_goal_id = g.id break except Exception as e: - print(f"Goal lookup failed during webhook receive: {e}") + logger.exception("Goal lookup failed during webhook receive: %s", e) signal = Signal( id=str(uuid.uuid4()), @@ -93,7 +96,12 @@ async def github_webhook_receiver(request: Request): goal_id=target_goal_id, ) store.insert_signal(signal) - print(f"✅ AUTOMATIC DETECTION: Saved {signal_data['event_type']} for user {user_id} and goal {target_goal_id}!") + logger.info( + "Automatic detection: saved %s for user %s and goal %s", + signal_data["event_type"], + user_id, + target_goal_id, + ) create_notification_if_new( user_id=user_id, diff --git a/src/lpi/store.py b/src/lpi/store.py index 2e3a8fa..63e9029 100644 --- a/src/lpi/store.py +++ b/src/lpi/store.py @@ -69,6 +69,7 @@ The clear_all() helper wipes both tables between test runs. """ +import logging import threading from datetime import datetime from typing import TYPE_CHECKING, Any, cast @@ -76,6 +77,8 @@ from lpi.config import settings from lpi.models import Goal, RecommendationFeedback, Signal +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from supabase import Client # type: ignore[attr-defined] @@ -94,7 +97,7 @@ def _get_client() -> "Client": from supabase import create_client # type: ignore[attr-defined] key = settings.supabase_service_role_key or settings.supabase_key - print("SUPABASE URL:", settings.supabase_url) + logger.debug("Supabase URL: %s", settings.supabase_url) if not key: raise RuntimeError( "Supabase service role key is required for backend writes. " @@ -353,8 +356,8 @@ def get_user_email(user_id: str) -> str | None: # Ensure the return type strictly matches str | None return str(email) if email else None return None - except Exception as e: - print(f"Error fetching email for user {user_id}: {e}") + except Exception: + logger.exception("Error fetching email for user %s", user_id) return None def update_user_profile(user_id: str, updates: dict) -> dict | None: @@ -365,8 +368,8 @@ def update_user_profile(user_id: str, updates: dict) -> dict | None: # Explicitly return a dict to satisfy the function signature return cast(dict, result.data[0]) return None - except Exception as e: - print(f"Error updating profile for user {user_id}: {e}") + except Exception: + logger.exception("Error updating profile for user %s", user_id) return None # ── Audit log verification (new — used by tests, also useful for admin tooling) ─ diff --git a/src/lpi/utils/logging.py b/src/lpi/utils/logging.py index 52bdb78..0a574e8 100644 --- a/src/lpi/utils/logging.py +++ b/src/lpi/utils/logging.py @@ -156,9 +156,11 @@ def log_transition( # below — same fix should be applied here as a follow-up (out of # scope for the signals audit-log bug this pass addresses). # Never let a logging failure break the update endpoint. - print( - f"[log_transition] WARNING: Supabase insert failed for " - f"goal {goal_id} ({from_phase}→{to_phase})" + logger.exception( + "[log_transition] Supabase insert failed for goal %s (%s→%s)", + goal_id, + from_phase, + to_phase, ) @@ -326,7 +328,9 @@ def log_system_event( except Exception: # NOTE: not yet migrated to logger.exception() — same follow-up as # log_transition() above, out of scope for this pass. - print(f"[log_system_event] WARNING: Supabase insert failed for event={event} level={level}") + logger.exception( + "[log_system_event] Supabase insert failed for event=%s level=%s", event, level + ) # ══════════════════════════════════════════════════════════════════════════════ diff --git a/tests/conftest.py b/tests/conftest.py index fb29a2e..423d274 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,17 +76,24 @@ def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) -def clear_store() -> Generator[None, None, None]: +def clear_store(request: pytest.FixtureRequest) -> Generator[None, None, None]: """Wipe all Supabase + in-memory state before and after every test. The `yield` splits setup (before) from teardown (after). Both sides are cleared so a failing test cannot pollute the next one. If Supabase is unreachable the fixture skips the test with a clear - message instead of raising a cryptic connection error. Tests that - only use in-memory scoring (test_scoring.py) create no Goals and - never call the store, so they run fine regardless. + message instead of raising a cryptic connection error. + + Tests marked @pytest.mark.unit never touch the store — they bypass + both the availability check and the wipe, so they ALWAYS run (locally + and in CI) even with no Supabase. Without this, pure unit tests were + silently skipped alongside the integration tests. """ + if request.node.get_closest_marker("unit"): + yield + return + if not _supabase_available(): pytest.skip("Local Supabase is not running. Start it with `supabase start` then re-run.") diff --git a/tests/test_cost_guard.py b/tests/test_cost_guard.py new file mode 100644 index 0000000..333ee07 --- /dev/null +++ b/tests/test_cost_guard.py @@ -0,0 +1,64 @@ +"""Tests for the daily LLM cost guard (src/lpi/cost_guard.py). + +These are pure unit tests — no Supabase, no network. They always run, +including in CI, so the cap enforcement can never silently regress the +way an integration-only test would. +""" + +import pytest + +from lpi import cost_guard, langgraph_agent +from lpi.config import settings + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def _clean_guard(): + cost_guard.reset_for_tests() + yield + cost_guard.reset_for_tests() + + +class TestBudgetCheck: + def test_allows_under_cap(self) -> None: + assert cost_guard.check_budget() is True + + def test_blocks_at_cap(self, monkeypatch) -> None: + monkeypatch.setattr(settings, "daily_cost_cap_usd", 0.01) + # One expensive anthropic-priced call blows a 1-cent cap. + cost_guard.record_usage("anthropic", input_tokens=2_000_000, output_tokens=0) + assert cost_guard.check_budget() is False + + def test_spend_accumulates(self) -> None: + first = cost_guard.record_usage("groq", input_tokens=1_000_000, output_tokens=0) + second = cost_guard.record_usage("groq", input_tokens=0, output_tokens=1_000_000) + assert cost_guard.spent_today() == pytest.approx(first + second) + + def test_unknown_provider_uses_conservative_pricing(self) -> None: + cost = cost_guard.record_usage("mystery-llm", input_tokens=1_000_000, output_tokens=0) + # Falls back to the anthropic (most expensive) price row. + assert cost == pytest.approx(3.00) + + def test_missing_usage_falls_back_to_estimate(self) -> None: + cost = cost_guard.record_usage("groq") + assert cost > 0 + + def test_day_rollover_resets_spend(self, monkeypatch) -> None: + import datetime + + cost_guard.record_usage("anthropic", input_tokens=2_000_000, output_tokens=0) + assert cost_guard.spent_today() > 0 + tomorrow = datetime.datetime.now(datetime.UTC).date() + datetime.timedelta(days=1) + monkeypatch.setattr(cost_guard, "_today", lambda: tomorrow) + assert cost_guard.spent_today() == 0.0 + assert cost_guard.check_budget() is True + + +class TestCallLlmEnforcement: + def test_call_llm_refused_when_over_cap(self, monkeypatch) -> None: + monkeypatch.setattr(settings, "daily_cost_cap_usd", 0.01) + monkeypatch.setattr(settings, "groq_api_key", "fake-key-should-never-be-used") + cost_guard.record_usage("anthropic", input_tokens=2_000_000, output_tokens=0) + # Over cap → _call_llm must bail out BEFORE touching any provider SDK. + assert langgraph_agent._call_llm("any prompt") is None