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
26 changes: 1 addition & 25 deletions auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from urllib.parse import parse_qs, urlencode, urlsplit

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response

from runtime_env import dev_autoguest_enabled, local_auth_bypass_enabled
Expand Down Expand Up @@ -683,16 +683,6 @@ def _render_login_html() -> str:
</html>"""


class MagicAuthSendRequest(BaseModel):
email: str = Field(..., max_length=320)


class MagicAuthVerifyRequest(BaseModel):
email: str = Field(..., max_length=320)
code: str = Field(..., min_length=6, max_length=12)
return_to: str | None = Field(default="/", max_length=500)


def sanitize_return_to_path(return_to: str | None) -> str:
if not return_to:
return "/"
Expand Down Expand Up @@ -1096,17 +1086,3 @@ def logout(request: Request) -> Response:
_clear_session_cookie(response, request)
response.delete_cookie(GUEST_COOKIE_NAME, path="/")
return response


@auth_router.post("/api/auth/magic-auth/send")
def send_magic_auth(request: Request, body: MagicAuthSendRequest):
raise HTTPException(
status_code=503, detail="Email sign-in is not enabled in this release."
)


@auth_router.post("/api/auth/magic-auth/verify")
def verify_magic_auth(request: Request, body: MagicAuthVerifyRequest):
raise HTTPException(
status_code=503, detail="Email sign-in is not enabled in this release."
)
4 changes: 2 additions & 2 deletions docs/adr/0002-llm-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The application **never** sees a Gemini `response`, a `dict`, a JSON string, or
| Structured telemetry log per call | `llm/client.py` |
| Token-usage extraction (normalized shape) | `llm/gemini_adapter.py` (raw) → `llm/client.py` (consumed) |

`LLMAdapter` is a `runtime_checkable` Protocol with a single primitive: `call_once(request) -> StructuredLLMResult` or raises a normalized error. `LLMClient` is the concrete wrapper that owns retry + telemetry. Adding a second provider is one new file (e.g., `llm/anthropic_adapter.py`) plus updating `llm/factory.py`.
`LLMClient` is the concrete wrapper that owns retry + telemetry around the Gemini call-once implementation. A second provider should introduce its own concrete runtime only when the product actually ships one; there is no one-implementation protocol layer kept alive for speculation.

### The architectural invariant

Expand All @@ -60,6 +60,6 @@ Across the entire `LLMError` hierarchy, the route returns **stable copy** to the
## Consequences

- **The product-quality test from the design spec — *"no socratink application code should import Gemini directly"* — is now an automated assertion**, not a hope. It enforces itself on every commit.
- **Switching providers is a one-file addition.** Once a second adapter exists (e.g., Anthropic), `LLM_PROVIDER=anthropic` flips the entire stack — including the `/api/extract` route — without touching application code.
- **Provider switching before a second provider existed.** Rejected after the Ponytail cleanup: provider-selection env advertised an app contract the product did not support.
- **One legacy exception remains** for `ai_service.py` until `drill_chat` and `generate_repair_reps` migrate. That exception is annotated in the test and removed in the next sweep. Until then, contributors writing new logic in `ai_service.py` should prefer the seam path over the legacy one (the test won't catch this — judgment matters).
- **Tests no longer patch private names.** The migration test for `extract_knowledge_map` injects a fake `LLMClient` instead of monkeypatching `_get_client` and `_call_gemini_with_retry`. Future LLM-touching tests follow the same pattern.
25 changes: 10 additions & 15 deletions llm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Import from `llm` directly. Submodules are implementation detail.

| Export | What it is |
| :--- | :--- |
| `LLMClient` | Owns retry, telemetry, and the call-once contract. Wraps an `LLMAdapter`. |
| `build_llm_client(api_key=None)` | Factory. Reads `LLM_PROVIDER` (default `"gemini"`) and `LLM_MODEL`. Optional per-call `api_key` override. |
| `LLMClient` | Owns retry, telemetry, and the call-once contract around the Gemini implementation. |
| `build_llm_client(api_key=None)` | Factory. Reads `LLM_MODEL`. Optional per-call `api_key` override. |
| `StructuredLLMRequest` | Input: prompt, response schema, generation config. |
| `StructuredLLMResult` | Output: `parsed` (instance of `request.response_schema`, never a dict), `usage`, `latency_ms`. |
| `TokenUsage` | `input_tokens`, `output_tokens`, `total_tokens`. Provider-normalized. |
Expand All @@ -23,8 +23,7 @@ Import from `llm` directly. Submodules are implementation detail.
| :--- | :--- |
| `__init__.py` | Public API surface. The only thing app code imports from. |
| `client.py` | `LLMClient`: retry policy, telemetry, the call-once orchestrator. |
| `factory.py` | `build_llm_client`: provider/model resolution from env. |
| `adapter.py` | `LLMAdapter` Protocol. Every provider implements `call_once`. |
| `factory.py` | `build_llm_client`: Gemini client construction and model resolution from env. |
| `gemini_adapter.py` | The **only** file allowed to import the Gemini SDK. |
| `types.py` | `StructuredLLMRequest`, `StructuredLLMResult`, `TokenUsage`. |
| `errors.py` | Normalized exception hierarchy. |
Expand All @@ -35,9 +34,9 @@ Import from `llm` directly. Submodules are implementation detail.
imported only from `llm/gemini_adapter.py`. There is an **architectural
isolation test** that fails CI if any other module imports the Gemini SDK
directly. Do not move the import to satisfy a refactor.
- **Adapters don't retry, don't log, don't cache.** `LLMClient` owns those.
Adapters do one thing: translate request → SDK call → result (or normalized
error). See the `LLMAdapter` docstring in `adapter.py`.
- **Gemini call-once code doesn't retry, log, or cache.** `LLMClient`
owns those. `gemini_adapter.py` translates request → SDK call → result (or
normalized error).
- **`StructuredLLMResult.parsed` is a model instance, not a dict.** Adapters
call the response_schema constructor before returning. App code should be
able to write `result.parsed.field` without a json-parse step.
Expand All @@ -47,13 +46,9 @@ Import from `llm` directly. Submodules are implementation detail.

## Footguns

- **`LLM_PROVIDER` defaults to `"gemini"`; nothing else is implemented yet.**
The factory raises `NotImplementedError` for any other value. Don't add a
provider by editing `factory.py` only — add the adapter, wire the env, and
add isolation tests for the new SDK.
- **Per-stage provider overrides are NOT implemented.** `LLM_PROVIDER_<STAGE>`
env vars are documented as planned but ignored. The factory uses one global
default.
- **Provider-selection env vars are not a supported app contract.** The product currently has
one implemented provider: Gemini. Add a second provider only when the product
has a concrete second runtime to preserve.
- **Schema validation happens in the adapter.** If the provider returns
unparseable JSON or a payload that doesn't match `response_schema`, the
adapter raises `LLMValidationError`. App-side code should not re-validate.
Expand All @@ -64,5 +59,5 @@ Import from `llm` directly. Submodules are implementation detail.
## Related

- Tests: `tests/test_llm_client.py`, `tests/test_gemini_adapter.py`, `tests/test_llm_factory.py`, `tests/test_extract_route_error_mapping.py`.
- Env contract: `GEMINI_API_KEY` is the only required value when `LLM_PROVIDER=gemini`. `LLM_MODEL` defaults to `gemini-2.5-flash`.
- Env contract: `GEMINI_API_KEY` is required by the Gemini implementation. `LLM_MODEL` defaults to `gemini-2.5-flash`.
- Architectural isolation test enforces: no Gemini SDK import outside `gemini_adapter.py`.
2 changes: 1 addition & 1 deletion llm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Public API for the LLM seam.

Application code imports everything it needs from ``llm`` directly.
Submodules (``types``, ``errors``, ``adapter``, ``client``, ``gemini_adapter``,
Submodules (``types``, ``errors``, ``client``, ``gemini_adapter``,
``factory``) are implementation detail; importing from them outside of
``llm/`` itself is discouraged. The architectural isolation test enforces
that the Gemini SDK is only imported in ``llm/gemini_adapter.py``.
Expand Down
38 changes: 0 additions & 38 deletions llm/adapter.py

This file was deleted.

6 changes: 3 additions & 3 deletions llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
import logging
import time
from dataclasses import dataclass, replace
from typing import Any, cast

from .adapter import LLMAdapter
from .errors import RetriableLLMError
from .types import StructuredLLMRequest, StructuredLLMResult

Expand All @@ -27,14 +27,14 @@
class LLMClient:
"""Application-facing client. Owns retry policy and telemetry."""

adapter: LLMAdapter
adapter: Any

def generate_structured(self, request: StructuredLLMRequest) -> StructuredLLMResult:
last_exc: Exception | None = None
for attempt in range(request.max_retries + 1):
start = time.perf_counter()
try:
result = self.adapter.call_once(request)
result = cast(StructuredLLMResult, self.adapter.call_once(request))
except RetriableLLMError as exc:
latency_ms = (time.perf_counter() - start) * 1000.0
self._log_failure(request, exc, attempt=attempt, latency_ms=latency_ms)
Expand Down
24 changes: 6 additions & 18 deletions llm/factory.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Factory for building the configured LLMClient.
"""Factory for building the Gemini-backed LLMClient.

Reads:
- ``LLM_PROVIDER`` (default: ``"gemini"``)
- ``LLM_MODEL`` (default per provider — currently ``"gemini-2.5-flash"``)
- ``LLM_MODEL`` (default ``"gemini-2.5-flash"``)

Optional ``api_key`` argument lets callers (e.g., the /api/extract route)
override the env-resolved key with a per-request key. The adapter still
Expand All @@ -15,24 +14,13 @@
from .client import LLMClient
from .gemini_adapter import GeminiAdapter

_DEFAULT_PROVIDER = "gemini"
_DEFAULT_MODELS = {"gemini": "gemini-2.5-flash"}
_DEFAULT_MODEL = "gemini-2.5-flash"


def build_llm_client(*, api_key: str | None = None) -> LLMClient:
"""Construct the configured LLMClient.

Provider selection is via ``LLM_PROVIDER`` (default ``"gemini"``).
Per-stage override env vars (``LLM_PROVIDER_<STAGE>``) are not yet
implemented; the factory currently uses one global default.
"""
provider = os.environ.get("LLM_PROVIDER", _DEFAULT_PROVIDER).strip().lower()
if provider != "gemini":
raise NotImplementedError(
f"LLM provider {provider!r} not implemented. Currently supported: 'gemini'."
)
model = os.environ.get("LLM_MODEL", _DEFAULT_MODELS[provider]).strip()
"""Construct the Gemini-backed LLMClient."""
model = os.environ.get("LLM_MODEL", _DEFAULT_MODEL).strip()
if not model:
raise ValueError(f"LLM_MODEL must be non-empty for provider {provider!r}.")
raise ValueError("LLM_MODEL must be non-empty.")
adapter = GeminiAdapter(api_key=api_key, model=model)
return LLMClient(adapter=adapter)
19 changes: 0 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,6 @@ class SourceAttachment(BaseModel):
class ExtractRequest(BaseModel):
"""Concept-creation submission.

Two payload shapes are accepted:

CURRENT (conversational concept creation):
{name, learner_goal?, starting_sketch, source, api_key?}

LEGACY (back-compat for text-only callers):
{text, api_key?}

Server-side validation in /api/extract enforces the current source-less
launch contract: source-less submits require a non-empty learner sketch.
learner_goal may frame route generation, but it is not
Expand All @@ -304,9 +296,6 @@ class ExtractRequest(BaseModel):
learner_goal: str | None = Field(None, max_length=1_000)
starting_sketch: str | None = Field(None, max_length=10_000)
source: SourceAttachment | None = None
# Legacy back-compat
text: str | None = Field(None, max_length=500_000)
# Common
api_key: str | None = Field(None, max_length=200)


Expand All @@ -322,14 +311,6 @@ def _resolve_extract_path(req: "ExtractRequest") -> dict:
learner_goal is forwarded only as relevance/scaffold context; it never
proves understanding.
"""
# Legacy {text} payload — back-compat path. Bypasses the new shape entirely.
if req.text is not None and req.name is None and req.source is None:
if not req.text.strip():
return {"path": "error", "status": 422,
"error": "missing_text", "message": "Source text required."}
return {"path": "extract", "text": req.text}

# New shape: name is mandatory
name = (req.name or "").strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate the remaining text-only smoke caller

Text-only requests now fall through to missing_concept here because the legacy branch was removed, but scripts/b9-python-smoke.py still documents and executes scenario 3 as POST /api/extract with only {"text": ...} and expects HTTP 200. That repo-owned smoke command will now report a failure even when extraction is healthy; update/delete that scenario or keep compatibility until the smoke is migrated.

Useful? React with 👍 / 👎.

if not name:
return {"path": "error", "status": 422,
Expand Down
4 changes: 2 additions & 2 deletions public/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1651,10 +1651,10 @@ html[data-motion="reduced"] .creation-dialog-shell.anim-antigravity-enter {
}
.creation-dialog-banner-slot {
/* Empty until guest/error banner injected. Separate from content so
buildContentInputUI's container.innerHTML writes don't wipe banner. */
source-panel writes don't wipe banner. */
}
.creation-dialog-content {
/* buildContentInputUI renders its form here; or guest message. */
/* source-panel renders its form here; or guest message. */
}
.creation-dialog-meta {
font-size: 0.75rem;
Expand Down
2 changes: 1 addition & 1 deletion public/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@

@layer components, legacy, paper;

@import '../styles.css?v=147' layer(components);
@import '../styles.css?v=148' layer(components);
@import '../antigravity.css?v=28' layer(legacy);
@import 'paper.css?v=12' layer(paper);
4 changes: 2 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=6">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=6">
<link rel="shortcut icon" href="/favicon-32x32.png?v=6">
<link rel="stylesheet" href="/css/index.css?v=149">
<link rel="stylesheet" href="/css/index.css?v=150">
<link rel="stylesheet" href="css/drill-chamber.css?v=9">
</head>

Expand Down Expand Up @@ -445,7 +445,7 @@ <h2 class="concept-header-title" id="concept-header-title"></h2>
<div id="tooltip-root" aria-hidden="true"></div>

<script src="js/drill-chamber.js?v=11"></script>
<script type="module" src="js/app.js?v=153"></script>
<script type="module" src="js/app.js?v=155"></script>
<script src="js/intro-particles.js?v=8"></script>
<script type="module" src="js/tooltips.js?v=4"></script>
<script type="module" src="js/floating-room-label.js?v=5"></script>
Expand Down
Loading
Loading