Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
53 changes: 50 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`.
55 changes: 55 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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 <Supabase JWT>
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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions scripts/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import random
import uuid

Expand All @@ -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 = "<token from supabase status / your login>"
# bash: export LPI_TEST_JWT=<token>
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}"}

Expand Down
104 changes: 104 additions & 0 deletions src/lpi/cost_guard.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions src/lpi/langgraph_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Expand Down
Loading
Loading