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
2 changes: 2 additions & 0 deletions apps/api/src/lunaris_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
get_video_storage,
)
from .live import router as live_router
from .live.session import router as live_session_router
from .routers import (
activity,
admin_users,
Expand Down Expand Up @@ -133,6 +134,7 @@ def _register_routers(app: FastAPI) -> None:
app.include_router(courses.router)
# Live's region, mounted as a whole; Studio's app never names lunaris_live itself.
app.include_router(live_router)
app.include_router(live_session_router)
app.include_router(briefs.router)
app.include_router(runs.router)
app.include_router(bridge.router)
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/lunaris_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ class Settings:
live_compile_max_concurrent: int = 1
live_extend_daily_cap: int = 50
live_graph_budget_usd: float = 10.0
#: How long a Live session runs before the director closes it (plan §6: 25-40 minutes). A
#: bound on the learner's wall clock, not on turns — a session of long, slow turns would
#: otherwise run for hours.
live_session_budget_s: float = 1800.0
#: Lunaris Live session admission. A session is bounded by its clock already, so only the
#: *opening* is rationed per owner per day — a runaway starts by opening sessions in a loop, and
#: capping turns would end sittings that were going well for a reason nobody could explain.
#: ``live_session_budget_usd`` is the ceiling on one session's whole spend, read from the
#: ledger's rollup: a runaway guard, not a ration — 0 turns it off.
live_session_daily_cap: int = 20
live_session_budget_usd: float = 2.0
device_bridge_liveness_s: float = _DEFAULT_BRIDGE_LIMITS.liveness_s
device_bridge_completion_timeout_s: float = _DEFAULT_BRIDGE_LIMITS.completion_timeout_s
# The explainer-video operator kill-switch (plan §3.0 item 5). Default OFF — fail-closed, so a
Expand Down Expand Up @@ -164,6 +175,9 @@ def get_settings() -> Settings:
live_compile_max_concurrent=_env_int("LUNARIS_LIVE_COMPILE_MAX_CONCURRENT", default=1),
live_extend_daily_cap=_env_int("LUNARIS_LIVE_EXTEND_DAILY_CAP", default=50),
live_graph_budget_usd=_env_float("LUNARIS_LIVE_GRAPH_BUDGET_USD", default=10.0),
live_session_budget_s=_env_float("LUNARIS_LIVE_SESSION_BUDGET_S", default=1800.0),
live_session_daily_cap=_env_int("LUNARIS_LIVE_SESSION_DAILY_CAP", default=20),
live_session_budget_usd=_env_float("LUNARIS_LIVE_SESSION_BUDGET_USD", default=2.0),
device_bridge_liveness_s=_env_float(
"LUNARIS_DEVICE_BRIDGE_LIVENESS_S", default=_DEFAULT_BRIDGE_LIMITS.liveness_s
),
Expand Down
39 changes: 31 additions & 8 deletions apps/api/src/lunaris_api/live/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,30 @@
#: Decomposition decides what the course is made of and every later phase reads it, so it runs on
#: the strong tier, not the bulk one. Same env knob Studio's tiers use, so a tenant's model choice
#: covers both products. (Splitting decomposition from spec authoring across two tiers is a
#: latency/cost lever T4 owns; one tier keeps this honest until there is a measurement to tune to.)
#: latency/cost lever; one tier keeps this honest until there is a measurement to tune to.)
_DEFAULT_MODEL = "claude-opus-4-8"


def resolve_strong_model() -> str:
"""The model Live's quality surfaces run on — the compiler's decomposition and the tutor.

One function rather than a constant per composition root, so a tenant's model choice cannot end
up meaning two different things inside one product: a session taught by a weaker model than the
map it walks would be a difference nobody configured.
"""
return resolve_config("LUNARIS_MODEL_STRONG") or _DEFAULT_MODEL


#: Grading one answer against one explicit do-statement is a classification, not a quality surface,
#: so it runs on the bulk tier — the same knob Studio's workers read.
_DEFAULT_WORKER_MODEL = "claude-haiku-4-5-20251001"


def resolve_worker_model() -> str:
"""The model Live's classification surfaces run on (A1)."""
return resolve_config("LUNARIS_MODEL_WORKER") or _DEFAULT_WORKER_MODEL


# One durable store per process, same lazy-client rationale as Studio's stores: the service-role
# client is built on first write, so the singleton needs no creds and no network until then.
_supabase_graph_store = SupabaseGraphStore()
Expand All @@ -37,8 +58,13 @@
_memory_graph_store = MemoryGraphStore()


def _resolve_graph_store(settings: Settings) -> IGraphStore:
"""Durable where Supabase is configured, in-process otherwise (offline dev and the suite)."""
def resolve_graph_store(settings: Settings) -> IGraphStore:
"""Durable where Supabase is configured, in-process otherwise (offline dev and the suite).

Public because the session plane composes against the *same* store: a session reading from a
different store than the compiler wrote to would find no maps, and it would read as a data
problem rather than the wiring one it is.
"""
return _supabase_graph_store if settings.has_supabase else _memory_graph_store


Expand All @@ -51,10 +77,7 @@ def _resolve_compiler(settings: Settings) -> IGraphCompiler:
"""
if settings.pipeline == "stub":
return StubGraphCompiler()
return ClaudeGraphCompiler(
resolve_config("LUNARIS_MODEL_STRONG") or _DEFAULT_MODEL,
deadline_s=settings.live_compile_deadline_s,
)
return ClaudeGraphCompiler(resolve_strong_model(), deadline_s=settings.live_compile_deadline_s)


@lru_cache
Expand Down Expand Up @@ -100,7 +123,7 @@ def get_live_graph_service(
"""
return LiveGraphService(
_resolve_compiler(settings),
_resolve_graph_store(settings),
resolve_graph_store(settings),
cost_event_store=cost_event_store,
subject_cost_store=subject_cost_store,
credential_resolver=get_live_credential_resolver(settings),
Expand Down
17 changes: 1 addition & 16 deletions apps/api/src/lunaris_api/live/graph_throttle.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,7 @@
from datetime import datetime

from ..daily_counter import DailyCounter


class LiveWorkRefusedError(Exception):
"""Base for work Live declines to start.

Subclasses set two class attributes the routers read so EVERY refusal maps the same way (one
``except`` clause, no per-reason branching): ``status_code`` and ``detail``, the learner-facing
sentence. The base seeds the exception message from ``detail`` so logs carry the reason rather
than a blank ``SomeError:``.
"""

status_code: int = 429
detail: str = "Live can't take that on right now."

def __init__(self, detail: str | None = None) -> None:
super().__init__(detail or self.detail)
from .work_refused import LiveWorkRefusedError


class LiveCompileBusyError(LiveWorkRefusedError):
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/lunaris_api/live/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

from ..dependencies import OptionalUserIdDep
from .dependencies import LiveGraphServiceDep
from .graph_throttle import LiveWorkRefusedError
from .schemas import CompileFailure, LiveGraphExtendRequest, LiveGraphRequest
from .work_refused import LiveWorkRefusedError

logger = structlog.get_logger()

Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/lunaris_api/live/session/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .router import router

__all__ = ["router"]
125 changes: 125 additions & 0 deletions apps/api/src/lunaris_api/live/session/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from functools import lru_cache
from typing import Annotated

from fastapi import Depends
from lunaris_live.session import (
ClaudeGrader,
ClaudeTutor,
IGrader,
IKnowledgeStore,
ISessionStore,
ITutor,
MemoryKnowledgeStore,
MemorySessionStore,
StubGrader,
StubTutor,
SupabaseKnowledgeStore,
SupabaseSessionStore,
)

from ...config import Settings, get_settings
from ...dependencies import CostEventStoreDep, SubjectCostStoreDep
from ..dependencies import (
get_live_credential_resolver,
resolve_graph_store,
resolve_strong_model,
resolve_worker_model,
)
from .service import LiveSessionService
from .throttle import LiveSessionThrottle

# One durable store per process — same lazy-client rationale as the graph store: the service-role
# client is built on first write, so the singleton needs no creds and no network until then.
_supabase_session_store = SupabaseSessionStore()
_supabase_knowledge_store = SupabaseKnowledgeStore()

# The in-memory fallbacks MUST be singletons: opening a session and the next turn of it are
# separate requests, so a per-request store would lose the session — and the learner's beliefs —
# between them.
_memory_session_store = MemorySessionStore()
_memory_knowledge_store = MemoryKnowledgeStore()


def _resolve_session_store(settings: Settings) -> ISessionStore:
"""Durable where Supabase is configured, in-process otherwise (offline dev and the suite)."""
return _supabase_session_store if settings.has_supabase else _memory_session_store


def _resolve_knowledge_store(settings: Settings) -> IKnowledgeStore:
"""Durable where Supabase is configured, in-process otherwise (offline dev and the suite)."""
return _supabase_knowledge_store if settings.has_supabase else _memory_knowledge_store


def get_live_tutor(settings: Annotated[Settings, Depends(get_settings)]) -> ITutor:
"""The model-backed tutor, or the deterministic one under ``LUNARIS_PIPELINE=stub``.

A dependency in its own right rather than something the service composes privately, because it
is the collaborator most worth substituting: the failure that matters most here is a tutor that
cannot speak, and a test can only stage that by putting a silent one in its place.

Teaching runs on the strong tier (A1) — the same tier the map was compiled on. It is the
quality surface of the whole product, and a session taught by a cheaper model than the map it
walks would be a difference no one chose.
"""
return StubTutor() if settings.pipeline == "stub" else ClaudeTutor(resolve_strong_model())


def get_live_grader(settings: Annotated[Settings, Depends(get_settings)]) -> IGrader:
"""The model-backed grader, or the deterministic one under ``LUNARIS_PIPELINE=stub``.

A dependency of its own for the same reason the tutor is: a grader that cannot answer is the
failure worth staging in a test, and an answer wrongly scored is the mistake that compounds —
every verdict it gets wrong is written into a belief the director acts on for the rest of the
session.

Runs on the worker tier (A1): teaching is the quality surface, judging one answer against one
explicit do-statement is a classification.
"""
return StubGrader() if settings.pipeline == "stub" else ClaudeGrader(resolve_worker_model())


@lru_cache
def _get_live_session_throttle(settings: Settings) -> LiveSessionThrottle:
"""The process-wide session throttle for these settings.

Cached on the frozen ``Settings`` value because the per-day counts have to be shared across
requests: the service is built per request, and a per-request throttle would never see the
openings it is supposed to be counting. Keyed on the whole ``Settings`` (not just the Live
fields) so it cannot drift as the config surface grows; tests reset it via
``_get_live_session_throttle.cache_clear()``.
"""
return LiveSessionThrottle(open_daily_cap=settings.live_session_daily_cap)


def get_live_session_service(
settings: Annotated[Settings, Depends(get_settings)],
tutor: Annotated[ITutor, Depends(get_live_tutor)],
grader: Annotated[IGrader, Depends(get_live_grader)],
cost_event_store: CostEventStoreDep,
subject_cost_store: SubjectCostStoreDep,
) -> LiveSessionService:
"""Live's session plane as a request dependency.

Takes the *same* graph store the compile plane writes to rather than composing a second one —
a session that read from a different store than the compiler wrote to would find no maps at all,
and it would look like a data problem rather than the wiring one it is.
"""
return LiveSessionService(
resolve_graph_store(settings),
_resolve_session_store(settings),
knowledge=_resolve_knowledge_store(settings),
tutor=tutor,
grader=grader,
session_budget_s=settings.live_session_budget_s,
# The ledger and the tenant's keys come from Studio's composition root: Live is a second
# product, not a second platform, so a tenant's spend and a tenant's key are the same ones
# either way. What differs is only the subject a cost is filed under (``LIVE_SESSION``).
cost_event_store=cost_event_store,
subject_cost_store=subject_cost_store,
credential_resolver=get_live_credential_resolver(settings),
throttle=_get_live_session_throttle(settings),
session_budget_usd=settings.live_session_budget_usd,
)


LiveSessionServiceDep = Annotated[LiveSessionService, Depends(get_live_session_service)]
Loading
Loading