Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/lpi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class Signal(SignalCreate):
id: str
user_id: str
timestamp: datetime



# ── Recommendation model ──────────────────────────────────────────────────────
Expand Down
26 changes: 20 additions & 6 deletions src/lpi/routers/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@

# ── Wave 2: POST /api/v1/signals/ ────────────────────────────────────────────


@router.post(
"/",
response_model=Signal,
Expand Down Expand Up @@ -106,9 +107,9 @@ def ingest_signal(
# fields (id, user_id, timestamp) on top.
now = datetime.now(UTC)
new_signal = Signal(
id=str(uuid.uuid4()), # UUID generated here, not by Postgres
user_id=user_id, # "default_user" in Phase 3
timestamp=now, # always UTC
id=str(uuid.uuid4()), # UUID generated here, not by Postgres
user_id=user_id, # "default_user" in Phase 3
timestamp=now, # always UTC
**signal.model_dump(), # stream, event_type, payload, source
)

Expand Down Expand Up @@ -137,11 +138,13 @@ def ingest_signal(
# Log to stdout — visible in uvicorn logs. Never breaks the endpoint.
print(f"[ingest_signal] WARNING: logging failed for signal {new_signal.id}: {exc}")

print(new_signal.model_dump())
return new_signal


# ── Wave 3: GET /api/v1/signals/ ─────────────────────────────────────────────


@router.get(
"/",
response_model=list[Signal],
Expand Down Expand Up @@ -169,18 +172,26 @@ def list_signals(
"exclude simulated signals."
),
),
start: datetime | None = Query(
default=None,
description="Return signals created at or after this UTC timestamp.",
),
end: datetime | None = Query(
default=None,
description="Return signals created at or before this UTC timestamp.",
),
limit: int = Query(
default=50,
ge=1, # minimum 1 row
le=200, # maximum 200 rows — prevents accidentally huge responses
ge=1, # minimum 1 row
le=200, # maximum 200 rows — prevents accidentally huge responses
description=(
"Max rows per page (1–200). Use with offset for pagination. "
"Default 50 is enough for dashboards and the rec engine."
),
),
offset: int = Query(
default=0,
ge=0, # cannot be negative
ge=0, # cannot be negative
description=(
"Number of rows to skip. Page 1 = offset 0. "
"Page 2 = offset 50 (if limit=50). "
Expand Down Expand Up @@ -226,13 +237,16 @@ def list_signals(
stream=stream,
event_type=event_type,
source=source,
start=start,
end=end,
limit=limit,
offset=offset,
)


# ── Bonus: GET /api/v1/signals/{signal_id} ───────────────────────────────────


@router.get(
"/{signal_id}",
response_model=Signal,
Expand Down
32 changes: 16 additions & 16 deletions src/lpi/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"""

import threading
from datetime import datetime
from typing import TYPE_CHECKING, cast

from lpi.config import settings
Expand All @@ -78,6 +79,7 @@ def _get_client() -> "Client":
authenticated server-side operations bypass RLS as designed.
"""
from supabase import create_client # type: ignore[attr-defined]

key = settings.supabase_service_role_key or settings.supabase_key
if not key:
raise RuntimeError(
Expand All @@ -99,6 +101,7 @@ def _get_client() -> "Client":

# ── Goals ──────────────────────────────────────────────────────────────────────


def get_goal(goal_id: str) -> Goal | None:
"""Fetch a single goal by its UUID. Returns None if not found."""
result = _get_client().table("goals").select("*").eq("id", goal_id).execute()
Expand Down Expand Up @@ -133,9 +136,7 @@ def insert_goal(goal: Goal) -> Goal:

def update_goal(goal_id: str, updates: dict) -> Goal:
"""Apply a partial update dict to a goal row. Returns the updated goal."""
result = (
_get_client().table("goals").update(updates).eq("id", goal_id).execute()
)
result = _get_client().table("goals").update(updates).eq("id", goal_id).execute()
return Goal(**cast(dict, result.data[0]))


Expand All @@ -153,6 +154,7 @@ def delete_goal(goal_id: str) -> None:
# Pattern: identical to the goals functions above. If you understand
# how insert_goal / list_goals / get_goal work, these are the same.


def insert_signal(signal: Signal) -> Signal:
"""Persist a new signal row to the activity_signals Supabase table.

Expand All @@ -163,9 +165,7 @@ def insert_signal(signal: Signal) -> Signal:
Returns the original signal unchanged (Supabase returns the inserted
row but we already have it — no need to re-parse it).
"""
_get_client().table("activity_signals").insert(
signal.model_dump(mode="json")
).execute()
_get_client().table("activity_signals").insert(signal.model_dump(mode="json")).execute()
return signal


Expand All @@ -174,6 +174,8 @@ def list_signals(
stream: str | None = None,
event_type: str | None = None,
source: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Signal]:
Expand Down Expand Up @@ -232,6 +234,11 @@ def list_signals(
query = query.eq("event_type", event_type)
if source:
query = query.eq("source", source)
if start is not None:
query = query.gte("timestamp", start.isoformat())

if end is not None:
query = query.lte("timestamp", end.isoformat())

# Sort newest-first, then apply pagination.
# .order("timestamp", desc=True) → ORDER BY timestamp DESC
Expand All @@ -253,20 +260,15 @@ def get_signal(signal_id: str) -> Signal | None:
Used by GET /api/v1/signals/{signal_id} — not yet in the router
but defined here so Phase 4 can use it without touching the store.
"""
result = (
_get_client()
.table("activity_signals")
.select("*")
.eq("id", signal_id)
.execute()
)
result = _get_client().table("activity_signals").select("*").eq("id", signal_id).execute()
if not result.data:
return None
return Signal(**cast(dict, result.data[0]))


# ── Test helper ───────────────────────────────────────────────────────────────


def clear_all() -> None:
"""Wipe all data from goals and activity_signals. Call ONLY from tests.

Expand All @@ -284,9 +286,7 @@ def clear_all() -> None:
in conftest.py, so tests never see each other's data.
"""
# Wipe all goals rows
_get_client().table("goals").delete().neq(
"user_id", "__sentinel_never_exists__"
).execute()
_get_client().table("goals").delete().neq("user_id", "__sentinel_never_exists__").execute()

# Wipe all activity_signals rows (Phase 3 addition)
_get_client().table("activity_signals").delete().neq(
Expand Down
8 changes: 7 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ def _supabase_available() -> bool:
try:
store.list_goals()
return True
except Exception:
except Exception as e:
print("SUPABASE CHECK FAILED:", repr(e))
return False


Expand Down Expand Up @@ -107,6 +108,10 @@ def client() -> TestClient:
test_client.headers.update({"Authorization": f"Bearer {_make_token()}"})
return test_client

@pytest.fixture
def unauthenticated_client() -> TestClient:
"""FastAPI test client without Authorization header."""
return TestClient(app)

@pytest.fixture
def sample_goal() -> dict:
Expand All @@ -129,5 +134,6 @@ def sample_signal() -> dict:
return {
"stream": "boardy",
"event_type": "match_created",
"timestamp": "2026-06-11T10:00:00Z",
"payload": {"person_a": "Alice", "person_b": "Bob", "score": 0.85},
}
Loading
Loading