fix: WebP figures, the openai SDK removed, and a voice a site can add to
Three things landed together; the message names all of them, because a commit that mentions one is a commit nobody finds the other two in. **Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached in the media library — are WebP now, with `questions.image_path`, `questions.explanation_image_path` and `media_assets.path` repointed together. Serving already converted them on the way out, so nothing was broken; this removes the step and makes what is stored the same thing that is served. The originals stay: they are the only copy of what came out of the PDF, they cost a few megabytes between them, and a conversion nobody can undo is not one to run against a live bank. Paths are found by what the columns say rather than by listing a bucket, because three tables record them and updating two would be worse than none. **The openai SDK is gone.** Ten call sites — one more than the map said, the Celery article drafter — every one of them a POST with a JSON body, and not one reading usage, cost, tool calls or logprobs. Every other call to the same proxy was already plain httpx: embeddings, the ChromaDB embedding function, speech both ways, model discovery, the vision probe. So this deletes an abstraction rather than swapping one for another, and leaves one HTTP client instead of two. `chat()` and `achat()` return the message content; a `ProxyError` carries the status and the first 500 characters of the body, which is where the proxy explains itself. Behaviour is preserved deliberately, including a 600-second fallback timeout for the four call sites that were running on the SDK's ten-minute default. Lowering that is a real change and belongs in its own commit. Proved against the live proxy on both services rather than only against mocks: a completion, an async completion, a real 400 the vision probe still classifies as a refusal, 407 models read from the catalogue, and a word read off an image. **Voice.** A chosen voice is honoured whatever serves it. The prefix check only accepted a locally served one, so a site adding a hosted voice would offer it in Settings, save the learner's choice, and then quietly read every question in the default voice. The list has always come from the database — adding a voice is a row in Settings → AI models, never a code change. And the sign-in page stops offering a locked door: `signup-policy` reports whether registration is open at all, and the Sign up link goes when it is not. The switch existed and the only way to discover it was to fill the form in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
3f57bda6aa
commit
3418ed023b
31 changed files with 1164 additions and 167 deletions
35
backend/alembic/versions/f8091a2b3c4d_login_links.py
Normal file
35
backend/alembic/versions/f8091a2b3c4d_login_links.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Sign-in links sent by email.
|
||||
|
||||
Only the fingerprint of each token is stored: a row here is a session waiting
|
||||
to happen, and a table of usable ones is worth more to a reader of the database
|
||||
than a table of verification tokens ever was.
|
||||
|
||||
Revision ID: f8091a2b3c4d
|
||||
Revises: e7f8091a2b3c
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "f8091a2b3c4d"
|
||||
down_revision = "e7f8091a2b3c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS login_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
consumed_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
# Both lookups this table serves: one by token when a link is followed,
|
||||
# one by owner when a newer request retires the older links.
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_login_links_token_hash ON login_links (token_hash)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_login_links_user_id ON login_links (user_id)")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS login_links")
|
||||
|
|
@ -14,6 +14,7 @@ from app.routers import auth, documents, quizzes, attempts, admin, tts, nextclou
|
|||
from app.routers import access
|
||||
from app.routers import feedback
|
||||
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
|
||||
from app.routers import login_link
|
||||
from app.utils.auth import get_password_hash
|
||||
|
||||
|
||||
|
|
@ -609,6 +610,7 @@ app.add_middleware(RequestLoggingMiddleware)
|
|||
app.include_router(uploads.router)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(login_link.router, prefix="/api/auth", tags=["auth"])
|
||||
# Counts the landing page states about itself. No auth: a stranger reads it.
|
||||
app.include_router(public.router, prefix="/api/public", tags=["public"])
|
||||
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
|
||||
|
|
|
|||
|
|
@ -42,3 +42,4 @@ __all__ = [
|
|||
from app.models.feedback import QuestionFeedback # noqa: F401
|
||||
from app.models.invite import InviteCode # noqa: F401
|
||||
from app.models.draft_question import DraftBatch, DraftQuestion # noqa: F401
|
||||
from app.models.login_link import LoginLink # noqa: F401
|
||||
|
|
|
|||
29
backend/app/models/login_link.py
Normal file
29
backend/app/models/login_link.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LoginLink(Base):
|
||||
"""One issued sign-in link.
|
||||
|
||||
Only the fingerprint of the token is kept. Unlike a verification or reset
|
||||
token, this one is a session in waiting — anybody who can read the table
|
||||
could sign in as its owner — so the row holds something that can be
|
||||
compared against but not replayed.
|
||||
"""
|
||||
|
||||
__tablename__ = "login_links"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
#: SHA-256 of the token, hex. Not bcrypt: the token is 256 bits of
|
||||
#: randomness rather than something a person chose, so there is nothing for
|
||||
#: a slow hash to buy — only the lookup would get slower.
|
||||
token_hash = Column(String(64), unique=True, nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
#: Set the moment the link is spent, and also when a later request retires
|
||||
#: it. Both mean the same thing to everyone who reads this: not usable.
|
||||
consumed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
|
@ -404,13 +404,13 @@ def test_model(
|
|||
return {"message": f"✓ {model.model_id} read “{TEST_WORD}” off the test image"}
|
||||
|
||||
try:
|
||||
from app.services.ai_service import get_client
|
||||
response = get_client(model.api_key).chat.completions.create(
|
||||
from app.services.ai_service import chat
|
||||
reply = chat(
|
||||
model=model.model_id,
|
||||
messages=[{"role": "user", "content": "Reply with only the word: OK"}],
|
||||
max_tokens=10,
|
||||
)
|
||||
reply = response.choices[0].message.content.strip()
|
||||
api_key=model.api_key,
|
||||
).strip()
|
||||
return {"message": f"✓ {model.model_id} → {reply!r}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -187,12 +187,11 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
|
|||
*history, {"role": "user", "content": question}]
|
||||
|
||||
try:
|
||||
from app.services.ai_service import get_async_client
|
||||
from app.services.ai_service import achat
|
||||
|
||||
client = get_async_client(api_key)
|
||||
response = await client.chat.completions.create(
|
||||
model=model_id, messages=messages, max_tokens=700, temperature=0.3)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
raw = (await achat(
|
||||
model=model_id, messages=messages, max_tokens=700, temperature=0.3,
|
||||
api_key=api_key) or "").strip()
|
||||
except Exception:
|
||||
log.error("AI Mode failed for user %s", current_user.id, exc_info=True)
|
||||
raise HTTPException(502, "AI Mode is temporarily unavailable. Try again in a moment.")
|
||||
|
|
|
|||
|
|
@ -73,8 +73,14 @@ def signup_policy(db: Session = Depends(get_db)):
|
|||
# The very first account is always allowed, or a new install could lock
|
||||
# itself out before an administrator exists to issue a code.
|
||||
if db.query(User).count() == 0:
|
||||
return {"invite_required": False, "first_user": True}
|
||||
return {"invite_required": site_settings.get_flag("invite_only"), "first_user": False}
|
||||
return {"invite_required": False, "first_user": True, "registration_open": True}
|
||||
# Whether anyone may join at all, so the sign-in page can stop offering a
|
||||
# door that is locked. It was offering one: registration can be turned off
|
||||
# site-wide, and the only way to find out was to fill the form in and be
|
||||
# refused.
|
||||
return {"invite_required": site_settings.get_flag("invite_only"),
|
||||
"first_user": False,
|
||||
"registration_open": site_settings.get_flag("registration_enabled")}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
|
|
|
|||
|
|
@ -1470,7 +1470,7 @@ def ai_generate_standalone(
|
|||
"""Generate content using AI without requiring a saved lesson."""
|
||||
_course_owner_or_admin(course_id, current_user, db)
|
||||
|
||||
from app.services.ai_service import get_model_for_task, get_client
|
||||
from app.services.ai_service import get_model_for_task, chat
|
||||
|
||||
if data.model_id:
|
||||
model_id = data.model_id
|
||||
|
|
@ -1495,15 +1495,16 @@ def ai_generate_standalone(
|
|||
user_msg = f"Existing content:\n{data.existing_content}\n\nInstructions: {data.prompt}"
|
||||
|
||||
try:
|
||||
response = get_client(api_key).chat.completions.create(
|
||||
generated = chat(
|
||||
model=model_id,
|
||||
messages=[
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
max_tokens=4000,
|
||||
api_key=api_key,
|
||||
)
|
||||
return {"generated_content": response.choices[0].message.content, "model_used": model_id}
|
||||
return {"generated_content": generated, "model_used": model_id}
|
||||
except Exception as e:
|
||||
logger.error(f"AI generation failed: {e}")
|
||||
raise HTTPException(status_code=502, detail="AI generation failed")
|
||||
|
|
@ -1529,7 +1530,7 @@ def ai_generate_content(
|
|||
if not lesson:
|
||||
raise HTTPException(status_code=404, detail="Lesson not found in this course")
|
||||
|
||||
from app.services.ai_service import get_model_for_task, get_client
|
||||
from app.services.ai_service import get_model_for_task, chat
|
||||
|
||||
if data.model_id:
|
||||
model_id = data.model_id
|
||||
|
|
@ -1576,15 +1577,15 @@ def ai_generate_content(
|
|||
)
|
||||
|
||||
try:
|
||||
response = get_client(api_key).chat.completions.create(
|
||||
generated = chat(
|
||||
model=model_id,
|
||||
messages=[
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
max_tokens=4000,
|
||||
api_key=api_key,
|
||||
)
|
||||
generated = response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"AI generation failed: {e}")
|
||||
raise HTTPException(status_code=502, detail="AI generation failed")
|
||||
|
|
|
|||
140
backend/app/routers/login_link.py
Normal file
140
backend/app/routers/login_link.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Ask for a sign-in link, and spend one.
|
||||
|
||||
Kept out of the auth router because these two routes answer to a rule the rest
|
||||
of that file does not: whatever the state of the address given, the reply is
|
||||
the same reply, and takes the same time to arrive. That is easy to break by
|
||||
adding an early return next to routes that quite reasonably have several.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginLinkRequest, LoginLinkConsume, Token
|
||||
from app.services import email_service, login_links, site_settings
|
||||
from app.utils.auth import check_rate_limit, create_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
#: The only thing this endpoint ever says. Not "we sent you a link" and not
|
||||
#: "no such account": either would turn the form into a way to ask the site
|
||||
#: whether a given person is a member of it.
|
||||
SENT = "If that address has an account, a sign-in link is on its way."
|
||||
|
||||
#: Wording and status are identical for an address that exists and one that
|
||||
#: does not — but the work is not, and a stopwatch reads the difference. Every
|
||||
#: reply is held to this floor, which is comfortably above the few milliseconds
|
||||
#: that issuing a link costs. Sending the mail is a background task and runs
|
||||
#: after the response, so it never shows up in the timing at all.
|
||||
MIN_SECONDS = 0.4
|
||||
|
||||
#: Three per quarter hour, per address. This is the limit that matters: the
|
||||
#: address is who the mail lands on, and the one thing an attacker cannot vary
|
||||
#: while still hitting the person they mean to hit. Three is enough for
|
||||
#: somebody who fumbles the first attempt and does not trust the second.
|
||||
PER_ADDRESS_CALLS, PER_ADDRESS_WINDOW = 3, 15 * 60
|
||||
|
||||
#: Twenty per hour, per source address. Deliberately the looser of the two: a
|
||||
#: hospital or university puts a whole department behind one address, so a
|
||||
#: tight limit here locks out real people, and it barely inconveniences anyone
|
||||
#: renting a hundred addresses. It is here to protect the site's own sending
|
||||
#: reputation from one machine working through a word list, not to protect any
|
||||
#: individual mailbox — the per-address limit does that.
|
||||
PER_IP_CALLS, PER_IP_WINDOW = 20, 60 * 60
|
||||
|
||||
|
||||
def _sso_only() -> bool:
|
||||
return site_settings.get_flag("sso_only")
|
||||
|
||||
|
||||
@router.post("/login-link")
|
||||
async def request_login_link(
|
||||
data: LoginLinkRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Send a sign-in link to an address, if that address has an account."""
|
||||
started = time.perf_counter()
|
||||
|
||||
if _sso_only():
|
||||
raise HTTPException(status_code=403, detail="Email sign-in is disabled. Please use SSO.")
|
||||
|
||||
address = data.email.lower().strip()
|
||||
# Both limits are checked before the account is looked up, so that being
|
||||
# throttled cannot itself become the tell about whether an account exists.
|
||||
# The address is keyed by its fingerprint: Redis is a cache with a rather
|
||||
# more relaxed life than the database, and it has no business holding a
|
||||
# readable list of who has an account here.
|
||||
check_rate_limit(
|
||||
key=f"login_link:addr:{login_links.fingerprint(address)}",
|
||||
max_calls=PER_ADDRESS_CALLS,
|
||||
window_seconds=PER_ADDRESS_WINDOW,
|
||||
detail="Too many sign-in links requested for that address. Please wait a few minutes.",
|
||||
)
|
||||
client_ip = (request.client.host if request and request.client else "unknown")
|
||||
check_rate_limit(
|
||||
key=f"login_link:ip:{client_ip}",
|
||||
max_calls=PER_IP_CALLS,
|
||||
window_seconds=PER_IP_WINDOW,
|
||||
detail="Too many sign-in links requested from this network. Please wait a while.",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == address).first()
|
||||
if user is not None:
|
||||
token = login_links.issue(db, user)
|
||||
background_tasks.add_task(email_service.send_login_link_email, user.email, user.name, token)
|
||||
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed < MIN_SECONDS:
|
||||
await asyncio.sleep(MIN_SECONDS - elapsed)
|
||||
return {"message": SENT}
|
||||
|
||||
|
||||
@router.post("/login-link/consume", response_model=Token)
|
||||
def consume_login_link(data: LoginLinkConsume, db: Session = Depends(get_db)):
|
||||
"""Spend a link and hand back a session.
|
||||
|
||||
A POST rather than the link itself pointing here. Mail gateways, security
|
||||
scanners and preview panes fetch every URL in a message with GET, and a
|
||||
single-use token spent by a scanner is a link that is already dead when its
|
||||
owner clicks it. The mail points at a page in the app, which posts the
|
||||
token from JavaScript that no scanner runs.
|
||||
|
||||
The link is not tied to the browser that asked for it. Binding it — a nonce
|
||||
in a cookie, checked here — would break the ordinary case, which is asking
|
||||
on a laptop and reading mail on a phone, and it defends against almost
|
||||
nothing: somebody who can read the mailbox can simply ask for a link of
|
||||
their own. What is left is a link forwarded or leaked to a third party, and
|
||||
that is what the fifteen minutes and the one use are for.
|
||||
"""
|
||||
user = login_links.consume(db, data.token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That sign-in link is no longer valid. If you asked more than once, "
|
||||
"open the most recent email, or request a new link.",
|
||||
)
|
||||
|
||||
# Following the link proves control of the mailbox, which is exactly what
|
||||
# verification checks — and it is still not allowed to stand in for it.
|
||||
# Somebody can register an address that is not theirs, choose a password
|
||||
# and never verify; if the real owner's sign-in link marked that account
|
||||
# verified, they would be signing in to an account the squatter already has
|
||||
# the password to. The verification mail is in the same inbox.
|
||||
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
||||
if verification and verification.verified_at is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Email not verified. Please check your inbox and verify your email before signing in.",
|
||||
)
|
||||
|
||||
logger.info("Sign-in link spent by user %s", user.id)
|
||||
return Token(access_token=create_access_token(data={"sub": user.email}))
|
||||
|
|
@ -348,15 +348,15 @@ async def chat(
|
|||
messages.append({"role": msg.role, "content": msg.content})
|
||||
|
||||
try:
|
||||
from app.services.ai_service import DEFAULT_TIMEOUT, get_async_client
|
||||
client = get_async_client(api_key, timeout=DEFAULT_TIMEOUT)
|
||||
response = await client.chat.completions.create(
|
||||
from app.services.ai_service import DEFAULT_TIMEOUT, achat
|
||||
raw = (await achat(
|
||||
model=model_id,
|
||||
messages=messages,
|
||||
max_tokens=600,
|
||||
temperature=0.4,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
api_key=api_key,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)).strip()
|
||||
|
||||
# Parse out follow-up suggestions (lines starting with "> ")
|
||||
lines = raw.splitlines()
|
||||
|
|
|
|||
|
|
@ -83,17 +83,17 @@ def text_to_speech(
|
|||
# never chosen one. A caller may still name a voice, which is how a preview
|
||||
# in Settings plays the one being considered rather than the one in force.
|
||||
wanted = request.voice or current_user.tts_voice
|
||||
if wanted and wanted.startswith("local-"):
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.model_id == wanted,
|
||||
).first()
|
||||
if not config:
|
||||
model_id, api_key = _default_tts_model(db)
|
||||
else:
|
||||
model_id = config.model_id
|
||||
api_key = config.api_key or None
|
||||
# Any active voice, not only a locally served one. The prefix check that
|
||||
# used to guard this meant a site adding a hosted voice would offer it in
|
||||
# Settings, save the learner's choice, and then quietly read every question
|
||||
# in the default voice instead.
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.model_id == wanted,
|
||||
).first() if wanted else None
|
||||
if config:
|
||||
model_id, api_key = config.model_id, config.api_key or None
|
||||
else:
|
||||
model_id, api_key = _default_tts_model(db)
|
||||
|
||||
|
|
|
|||
|
|
@ -52,3 +52,11 @@ class ForgotPasswordRequest(BaseModel):
|
|||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class LoginLinkRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class LoginLinkConsume(BaseModel):
|
||||
token: str
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
|
@ -15,51 +15,104 @@ logger = logging.getLogger(__name__)
|
|||
# sending. There is no routing left to defeat.
|
||||
|
||||
|
||||
def _client_base_url() -> str | None:
|
||||
"""The proxy speaks OpenAI's HTTP API, and the SDK appends `chat/completions`
|
||||
to the root it is given, so the root has to carry the `/v1` that the audio
|
||||
calls below — and vector_service and embedding_service — append by hand.
|
||||
LITELLM_API_BASE is configured without it."""
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
return base.removesuffix("/v1") + "/v1" if base else None
|
||||
class ProxyError(RuntimeError):
|
||||
"""A non-2xx answer from the completions proxy.
|
||||
|
||||
Every call site catches `Exception` and turns it into a 502, so the type
|
||||
matters less than what it carries. Two things are worth carrying:
|
||||
|
||||
* `status_code`, because `vision_service._probe` sorts refusals by it — a
|
||||
4xx to a one-token call holding nothing but a white square is the model
|
||||
saying it will not take images, and that verdict is cached, while a 5xx
|
||||
or a timeout says nothing and is not. The SDK's error exposed the same
|
||||
attribute name, so the probe needs no change.
|
||||
* the start of the body, because the proxy explains refusals there and a
|
||||
log line reading only "400" has never once been enough.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, body: str):
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
super().__init__(f"proxy returned {status_code}: {body}")
|
||||
|
||||
|
||||
def _client_kwargs(api_key: str | None, timeout: float | None) -> dict:
|
||||
kwargs = {
|
||||
# The SDK refuses to construct a client with no key at all, so an
|
||||
# unconfigured deployment would fail here rather than at the call —
|
||||
# turning what every caller handles as a 502 into a 500. Let the
|
||||
# endpoint reject the placeholder instead.
|
||||
"api_key": api_key or settings.LITELLM_API_KEY or os.environ.get("OPENAI_API_KEY") or "missing",
|
||||
# Left at its default the SDK repeats connection errors and 429/5xx
|
||||
# twice on its own. extract_questions already has its own three
|
||||
# attempts, which would become nine.
|
||||
"max_retries": 0,
|
||||
}
|
||||
base = _client_base_url()
|
||||
if base:
|
||||
kwargs["base_url"] = base
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
return kwargs
|
||||
|
||||
|
||||
#: The SDK reads for ten minutes by default and this client retries nothing, so
|
||||
#: a stalled connection is a stalled request — three of them in extraction,
|
||||
#: which does its own retrying. Two minutes is longer than any answer here has
|
||||
#: ever legitimately taken.
|
||||
#: Two minutes is longer than any answer a person waits on here has ever
|
||||
#: legitimately taken, and nothing retries underneath (see `chat`), so a
|
||||
#: stalled connection is a stalled request — three of them in extraction, which
|
||||
#: does its own retrying.
|
||||
DEFAULT_TIMEOUT = 120.0
|
||||
|
||||
def get_client(api_key: str | None = None, timeout: float | None = None) -> OpenAI:
|
||||
"""Blocking client for the completions proxy. Every call site goes through
|
||||
here: the same three settings assembled by hand at each one is how one of
|
||||
them ends up not sending the api_base."""
|
||||
return OpenAI(**_client_kwargs(api_key, timeout))
|
||||
#: What a call gets when it names no timeout. httpx's own default is five
|
||||
#: seconds, which no completion survives, so the choice cannot be left to it.
|
||||
#: Ten minutes is what the SDK gave these same call sites before it was
|
||||
#: removed, and the longest of them drafts a 4000-token article, so it is kept
|
||||
#: rather than lowered — shortening it is a visible change and belongs in its
|
||||
#: own commit. Anything a person is sitting in front of should pass
|
||||
#: DEFAULT_TIMEOUT instead.
|
||||
FALLBACK_TIMEOUT = 600.0
|
||||
|
||||
|
||||
def get_async_client(api_key: str | None = None, timeout: float | None = None) -> AsyncOpenAI:
|
||||
"""Async counterpart to get_client, for the request-path chat endpoints."""
|
||||
return AsyncOpenAI(**_client_kwargs(api_key, timeout))
|
||||
def _endpoint() -> str:
|
||||
"""The proxy speaks OpenAI's HTTP API. `LITELLM_API_BASE` is configured as
|
||||
the bare host, and every other hand-rolled call to it in this codebase —
|
||||
embeddings, transcription, speech — appends the `/v1` itself, so this one
|
||||
does too rather than inventing a third convention. With nothing configured
|
||||
the address is OpenAI's own, which is where the SDK went by default and
|
||||
where `text_to_speech` below still goes."""
|
||||
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1")
|
||||
return f"{base}/v1/chat/completions"
|
||||
|
||||
|
||||
def _headers(api_key: str | None) -> dict:
|
||||
# An unconfigured deployment should fail at the call, the way every caller
|
||||
# already handles, rather than earlier and louder somewhere else.
|
||||
key = api_key or settings.LITELLM_API_KEY or os.environ.get("OPENAI_API_KEY") or "missing"
|
||||
return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _body(model: str, messages: list[dict], params: dict) -> dict:
|
||||
return {"model": model, "messages": messages, **params}
|
||||
|
||||
|
||||
def _content(response: httpx.Response) -> str | None:
|
||||
"""The assistant's message, or the failure that explains itself.
|
||||
|
||||
Nothing in this codebase reads usage, cost, tool calls or logprobs off a
|
||||
completion — nine call sites, all of them `choices[0].message.content` —
|
||||
so the envelope is unwrapped here instead of nine times over. A caller that
|
||||
one day needs more should get the parsed body, not a second return value.
|
||||
"""
|
||||
if response.status_code >= 400:
|
||||
# Read the body before raising: it is where the proxy says *why*, and
|
||||
# it is the difference between a useful log line and a number.
|
||||
raise ProxyError(response.status_code, response.text[:500])
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def chat(*, model: str, messages: list[dict], api_key: str | None = None,
|
||||
timeout: float | None = None, **params) -> str | None:
|
||||
"""One blocking completion, returning the assistant's message text.
|
||||
|
||||
Extra keyword arguments (`temperature`, `max_tokens`, …) go into the JSON
|
||||
body untouched, so this is the OpenAI request with the boilerplate — URL,
|
||||
key, timeout — filled in once instead of at each call site.
|
||||
|
||||
No retries: `extract_questions` already makes three attempts of its own and
|
||||
anything retrying underneath would quietly make that nine.
|
||||
"""
|
||||
with httpx.Client(timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client:
|
||||
return _content(client.post(_endpoint(), headers=_headers(api_key),
|
||||
json=_body(model, messages, params)))
|
||||
|
||||
|
||||
async def achat(*, model: str, messages: list[dict], api_key: str | None = None,
|
||||
timeout: float | None = None, **params) -> str | None:
|
||||
"""Async counterpart to `chat`, for the request-path chat endpoints."""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client:
|
||||
return _content(await client.post(_endpoint(), headers=_headers(api_key),
|
||||
json=_body(model, messages, params)))
|
||||
|
||||
|
||||
EXTRACTION_PROMPT = """You are extracting questions from a pediatric board review exam PDF.
|
||||
|
||||
|
|
@ -191,13 +244,13 @@ def extract_questions(
|
|||
for attempt in range(3):
|
||||
try:
|
||||
# Don't force JSON mode — let the model respond naturally and we parse it
|
||||
response = get_client(use_key, timeout=DEFAULT_TIMEOUT).chat.completions.create(
|
||||
response_text = chat(
|
||||
model=use_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1, # low temp for faithful extraction
|
||||
api_key=use_key,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
logger.info(f"Model raw response (first 500 chars): {response_text[:500]!r}")
|
||||
|
||||
# Try to parse JSON, handle markdown code blocks
|
||||
|
|
@ -289,8 +342,7 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None,
|
|||
}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
response = get_client(use_key, timeout=timeout).chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
return chat(**kwargs, api_key=use_key, timeout=timeout)
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
|
|
@ -311,8 +363,6 @@ def transcribe_audio(
|
|||
api_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Transcribe uploaded audio through the proxy's OpenAI-compatible audio endpoint."""
|
||||
import httpx
|
||||
|
||||
if not audio:
|
||||
return None
|
||||
|
||||
|
|
@ -450,7 +500,7 @@ def generate_tts_audio(
|
|||
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
|
||||
local-<model>:<voice> → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam)
|
||||
"""
|
||||
import httpx, base64
|
||||
import base64
|
||||
|
||||
use_model = model_id or "tts-1:alloy"
|
||||
|
||||
|
|
|
|||
|
|
@ -150,3 +150,23 @@ We received a request to reset your password. Click below to choose a new one.
|
|||
await _send(to_email, subject, _wrap(subject, md))
|
||||
|
||||
|
||||
|
||||
|
||||
async def send_login_link_email(to_email: str, name: str, token: str):
|
||||
url = f"{settings.APP_URL}/signin-link?token={token}"
|
||||
subject = "Your PedsHub sign-in link"
|
||||
md = f"""# Sign in to PedsHub
|
||||
|
||||
Hi **{name}**,
|
||||
|
||||
Here is the sign-in link you asked for. It signs you in on whichever device you open it on.
|
||||
|
||||
[button:Sign In]({url})
|
||||
|
||||
> Expires in **15 minutes** · Works once. If you asked more than once, only the newest link works. If you didn't ask for this, ignore this email — nobody can get in without it.
|
||||
|
||||
---
|
||||
|
||||
[link:Or copy this link]({url})
|
||||
"""
|
||||
await _send(to_email, subject, _wrap(subject, md))
|
||||
|
|
|
|||
90
backend/app/services/login_links.py
Normal file
90
backend/app/services/login_links.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Signing in by following a link sent to your own mailbox.
|
||||
|
||||
The link is a credential that sits in an inbox, so everything here is about
|
||||
keeping the window in which it is worth stealing as narrow as it can be while
|
||||
the flow still works: a short life, one use, and a newer request retiring
|
||||
whatever came before it.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.login_link import LoginLink
|
||||
from app.models.user import User
|
||||
|
||||
#: Fifteen minutes. Mail is normally delivered in seconds and whoever asked for
|
||||
#: the link is at the keyboard now, so five would nearly always do — except
|
||||
#: that greylisting defers a first delivery between a sender and a recipient
|
||||
#: who have never spoken, commonly by five to ten minutes, and that is exactly
|
||||
#: the case here for anybody signing in for the first time. Fifteen covers the
|
||||
#: deferral and very little else.
|
||||
#:
|
||||
#: Deliberately not the hour the password reset link gets: spending a reset
|
||||
#: link changes the password, which the owner notices the next time they sign
|
||||
#: in. Spending this one leaves no trace at all in front of the owner, so it
|
||||
#: does not get the same rope.
|
||||
LINK_TTL = timedelta(minutes=15)
|
||||
|
||||
#: Rows are dead weight once they are well past their expiry, and nothing reads
|
||||
#: them afterwards. Kept a day rather than deleted on the spot so that a
|
||||
#: support question about "the link did not work this morning" can still be
|
||||
#: answered from the table.
|
||||
KEEP_SPENT_FOR = timedelta(days=1)
|
||||
|
||||
|
||||
def fingerprint(value: str) -> str:
|
||||
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def issue(db: Session, user: User) -> str:
|
||||
"""Retire this user's outstanding links, mint a new one, return the token.
|
||||
|
||||
Superseding rather than accumulating: with three live at once — which is
|
||||
all the rate limit allows — "single use" stops meaning "one way in at a
|
||||
time", and a mailbox quietly collects credentials. The cost is that somebody who asks twice and then opens the
|
||||
first mail is refused — which is why the failure copy says to use the most
|
||||
recent email rather than only that the link is dead.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
db.query(LoginLink).filter(
|
||||
LoginLink.user_id == user.id,
|
||||
LoginLink.consumed_at.is_(None),
|
||||
).update({"consumed_at": now}, synchronize_session=False)
|
||||
db.query(LoginLink).filter(
|
||||
LoginLink.user_id == user.id,
|
||||
LoginLink.expires_at < now - KEEP_SPENT_FOR,
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(LoginLink(user_id=user.id, token_hash=fingerprint(token), expires_at=now + LINK_TTL))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume(db: Session, token: str) -> User | None:
|
||||
"""Spend a link and return whose it is, or None if it is no good.
|
||||
|
||||
One caller, one answer: an expired link, an already-spent link and a token
|
||||
that was never issued are all None. Telling them apart would tell whoever
|
||||
is guessing which of their guesses was once real, and none of the three
|
||||
leaves the person holding it anything to do but ask for another.
|
||||
"""
|
||||
row = db.query(LoginLink).filter(LoginLink.token_hash == fingerprint(token)).first()
|
||||
if row is None or row.expires_at <= datetime.utcnow():
|
||||
return None
|
||||
|
||||
# The claim is the UPDATE, not the read above: two requests carrying the
|
||||
# same token — a double click, or a mail client racing the person — both
|
||||
# pass the read, and only the one whose UPDATE matched a row still unspent
|
||||
# gets to sign in.
|
||||
claimed = db.query(LoginLink).filter(
|
||||
LoginLink.id == row.id,
|
||||
LoginLink.consumed_at.is_(None),
|
||||
).update({"consumed_at": datetime.utcnow()}, synchronize_session=False)
|
||||
db.commit()
|
||||
if not claimed:
|
||||
return None
|
||||
|
||||
return db.query(User).filter(User.id == row.user_id).first()
|
||||
|
|
@ -12,10 +12,19 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
#: name -> default. Anything not listed here cannot be set.
|
||||
FLAGS: dict[str, bool] = {
|
||||
#: Whether anybody new may join at all. Stored under the same key the
|
||||
#: registration route and the admin settings page have always used; it was
|
||||
#: simply not declared here, so the one helper that reads these flags
|
||||
#: refused it and every caller wrote its own Redis lookup instead.
|
||||
"registration_enabled": True,
|
||||
#: Whether a learner may create a public share link for a session.
|
||||
"sharing_enabled": True,
|
||||
#: Whether registering requires an invite code issued by an administrator.
|
||||
"invite_only": False,
|
||||
#: Whether the only way in is the identity provider. Read here as well as
|
||||
#: in the SSO routes because it governs every email-based way of signing
|
||||
#: in, link included, and each of those had been reading the key itself.
|
||||
"sso_only": False,
|
||||
#: Whether the AI tutor may be opened while a session is being sat. It is
|
||||
#: only ever offered in study mode — the tutor is given the correct answer
|
||||
#: and told it may reveal it, so during an exam it would simply hand it
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import time
|
|||
from dataclasses import dataclass, field
|
||||
|
||||
from app.config import settings
|
||||
from app.services.ai_service import get_client, get_configured_model
|
||||
from app.services.ai_service import chat, get_configured_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -221,13 +221,15 @@ def _probe(model_id: str, api_key: str | None) -> bool:
|
|||
return _probes[model_id]
|
||||
|
||||
try:
|
||||
get_client(api_key, timeout=30).chat.completions.create(
|
||||
chat(
|
||||
model=model_id,
|
||||
max_tokens=1,
|
||||
messages=[{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": _probe_image()}},
|
||||
{"type": "text", "text": "Reply with: ok"},
|
||||
]}],
|
||||
api_key=api_key,
|
||||
timeout=30,
|
||||
)
|
||||
verdict = True
|
||||
except Exception as error:
|
||||
|
|
@ -380,15 +382,16 @@ def _describe(images: list[Image], model_id: str, api_key: str | None,
|
|||
if context:
|
||||
prompt += f"\n\nWhere it appears:\n{context[:2000]}"
|
||||
|
||||
completion = get_client(api_key, timeout=120).chat.completions.create(
|
||||
text = (chat(
|
||||
model=model_id,
|
||||
temperature=0,
|
||||
max_tokens=700,
|
||||
messages=[{"role": "user", "content": [
|
||||
image_part(image), {"type": "text", "text": prompt},
|
||||
]}],
|
||||
)
|
||||
text = (completion.choices[0].message.content or "").strip()
|
||||
api_key=api_key,
|
||||
timeout=120,
|
||||
) or "").strip()
|
||||
if not text:
|
||||
raise VisionUnavailable(
|
||||
f"{model_id} is configured as the tool model but returned no "
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
|
|||
try:
|
||||
from app.models.article import Article
|
||||
from app.services import article_service
|
||||
from app.services.ai_service import get_model_for_task, get_client
|
||||
from app.services.ai_service import get_model_for_task, chat
|
||||
from app.config import settings
|
||||
|
||||
existing = db.get(Article, article_id) if article_id else None
|
||||
|
|
@ -626,10 +626,9 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
|
|||
instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "",
|
||||
existing=existing_block,
|
||||
)
|
||||
response = get_client(ai_api_key).chat.completions.create(
|
||||
raw = chat(
|
||||
model=ai_model_id, messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=4000, temperature=0.4)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
max_tokens=4000, temperature=0.4, api_key=ai_api_key).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
|
||||
if raw.endswith("```"):
|
||||
|
|
|
|||
|
|
@ -12,12 +12,6 @@ PyMuPDF==1.23.22
|
|||
# Thumbnails. It arrives transitively today, which is not a reason to let a
|
||||
# feature depend on somebody else's dependency tree.
|
||||
Pillow==12.3.0
|
||||
# Every completion goes to one OpenAI-compatible endpoint, the proxy in
|
||||
# LITELLM_API_BASE, with no streaming, tools, routing or fallbacks. litellm
|
||||
# stood here for that and brought a large dependency tree to do it; the openai
|
||||
# SDK it already depended on does the same work directly. Embeddings, model
|
||||
# discovery and the audio endpoints were on plain httpx all along.
|
||||
openai==3.13.0
|
||||
chromadb==0.4.24
|
||||
celery[redis]==5.3.6
|
||||
redis==5.0.1
|
||||
|
|
@ -25,6 +19,10 @@ fastapi-mail==1.4.1
|
|||
apscheduler==3.10.4
|
||||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.1
|
||||
# The only HTTP client in the process. Completions, embeddings, model
|
||||
# discovery and the audio endpoints all POST to the same OpenAI-compatible
|
||||
# proxy; litellm and then the openai SDK each stood in front of the
|
||||
# completions half of that, and neither was doing anything this was not.
|
||||
httpx==0.27.0
|
||||
boto3==1.34.69
|
||||
pgvector==0.3.6
|
||||
|
|
|
|||
128
backend/scripts/convert_jpeg2000_figures.py
Normal file
128
backend/scripts/convert_jpeg2000_figures.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Rewrite JPEG 2000 figures as WebP.
|
||||
|
||||
Twenty-one stem images extracted from the source PDFs are `.jpx`. No browser
|
||||
but Safari draws JPEG 2000 — Chrome dropped it in 2015 — so those figures were
|
||||
blank for almost everybody. Serving already converts them on the way out and
|
||||
keeps the result, so nothing is broken by leaving them as they are; this
|
||||
removes the conversion step entirely and makes what is stored the same thing
|
||||
that is served.
|
||||
|
||||
Written as a migration of the bytes rather than a lazy rewrite because there
|
||||
are three places a path is recorded — `questions.image_path`,
|
||||
`questions.explanation_image_path` and `media_assets.path` — and a conversion
|
||||
that updated some of them would be worse than none.
|
||||
|
||||
The original is left in storage. It is the only copy of what came out of the
|
||||
PDF, it costs a few megabytes in total, and a conversion nobody can undo is not
|
||||
one to run against a live bank on a Friday.
|
||||
|
||||
Idempotent, and a dry run by default:
|
||||
|
||||
docker compose exec backend python -m scripts.convert_jpeg2000_figures
|
||||
docker compose exec backend python -m scripts.convert_jpeg2000_figures --apply
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.services import storage_service, thumbnails
|
||||
|
||||
#: What we are converting away from, by magic bytes rather than by name — the
|
||||
#: name is what got this wrong in the first place.
|
||||
SUFFIXES = (".jpx", ".jp2", ".jpf")
|
||||
|
||||
QUALITY = 90 # Higher than a thumbnail: this replaces the figure, not a preview.
|
||||
|
||||
|
||||
def converted_key(key: str) -> str:
|
||||
for suffix in SUFFIXES:
|
||||
if key.lower().endswith(suffix):
|
||||
return key[: -len(suffix)] + ".webp"
|
||||
return key + ".webp"
|
||||
|
||||
|
||||
def to_webp(data: bytes) -> bytes | None:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
try:
|
||||
image = ImageOps.exif_transpose(Image.open(io.BytesIO(data)))
|
||||
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
|
||||
out = io.BytesIO()
|
||||
image.save(out, format="WEBP", quality=QUALITY, method=4)
|
||||
return out.getvalue()
|
||||
except Exception as error:
|
||||
print(f" ! could not decode: {error}")
|
||||
return None
|
||||
|
||||
|
||||
def rows_with_jpeg2000(db):
|
||||
"""Every path that names a JPEG 2000 file, wherever it is recorded."""
|
||||
like = " OR ".join(f"lower({{col}}) LIKE '%{s}'" for s in SUFFIXES)
|
||||
found: dict[str, list[tuple[str, int, str]]] = {}
|
||||
for table, columns, key in (("questions", ("image_path", "explanation_image_path"), "id"),
|
||||
("media_assets", ("path",), "id")):
|
||||
for column in columns:
|
||||
clause = like.format(col=column)
|
||||
for row in db.execute(sa_text(
|
||||
f"SELECT {key} AS id, {column} AS path FROM {table} "
|
||||
f"WHERE {column} IS NOT NULL AND ({clause})")):
|
||||
found.setdefault(row.path, []).append((table, row.id, column))
|
||||
return found
|
||||
|
||||
|
||||
def main(apply: bool) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
found = rows_with_jpeg2000(db)
|
||||
if not found:
|
||||
print("Nothing left in JPEG 2000.")
|
||||
return 0
|
||||
|
||||
print(f"{len(found)} file(s) referenced by {sum(len(v) for v in found.values())} row(s)\n")
|
||||
converted = skipped = 0
|
||||
for key, references in sorted(found.items()):
|
||||
target = converted_key(key)
|
||||
where = ", ".join(f"{t}#{i}.{c}" for t, i, c in references)
|
||||
print(f" {key}\n -> {target} ({where})")
|
||||
|
||||
data = storage_service.load(key)
|
||||
if not data:
|
||||
print(" ! not in storage; left alone")
|
||||
skipped += 1
|
||||
continue
|
||||
if not apply:
|
||||
continue
|
||||
|
||||
if not storage_service.exists(target):
|
||||
webp = to_webp(data)
|
||||
if not webp:
|
||||
skipped += 1
|
||||
continue
|
||||
storage_service.save(target, webp, "image/webp")
|
||||
print(f" {len(data):,} bytes -> {len(webp):,}")
|
||||
for table, row_id, column in references:
|
||||
db.execute(sa_text(f"UPDATE {table} SET {column} = :new WHERE {key_of(table)} = :id"),
|
||||
{"new": target, "id": row_id})
|
||||
# The derivatives were made from the old key and are now orphaned.
|
||||
thumbnails.forget(key)
|
||||
converted += 1
|
||||
|
||||
if not apply:
|
||||
print("\ndry run. Pass --apply to write.")
|
||||
return 0
|
||||
db.commit()
|
||||
print(f"\ndone. {converted} converted, {skipped} left alone. "
|
||||
f"Originals are still in storage.")
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def key_of(table: str) -> str:
|
||||
return "id"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main("--apply" in sys.argv))
|
||||
|
|
@ -45,7 +45,7 @@ from app.database import SessionLocal
|
|||
from app.models.question import Question
|
||||
from app.routers.questions import _snapshot_question
|
||||
from app.services import vision_service
|
||||
from app.services.ai_service import get_client, get_configured_model, get_model_for_task
|
||||
from app.services.ai_service import chat, get_configured_model, get_model_for_task
|
||||
|
||||
SCRIPT_EDITOR_ID = None
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ def classify(stem: str, explanation: str, key: str, model: str,
|
|||
parts, _ = vision_service.image_context(
|
||||
None, [image], model_id=model, api_key=api_key, tool=tool,
|
||||
context=f"A figure printed with this board-exam question:\n{(stem or '')[:1000]}")
|
||||
completion = get_client(api_key).chat.completions.create(
|
||||
text = chat(
|
||||
model=model,
|
||||
temperature=0,
|
||||
messages=[{"role": "user", "content": [
|
||||
|
|
@ -129,8 +129,8 @@ def classify(stem: str, explanation: str, key: str, model: str,
|
|||
stem=(stem or "")[:4000] or "(empty)",
|
||||
explanation=(explanation or "")[:4000] or "(empty)")},
|
||||
]}],
|
||||
)
|
||||
text = completion.choices[0].message.content or ""
|
||||
api_key=api_key,
|
||||
) or ""
|
||||
except Exception as error: # a failed call must not decide anything
|
||||
return {"belongs": "unclear", "confidence": 0.0, "reason": f"model error: {error}"[:160]}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ here is not what a model says but what the server does with it. The safety
|
|||
property — an invented citation cannot survive — has to hold whatever comes back.
|
||||
"""
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink
|
||||
|
|
@ -16,12 +16,6 @@ from app.routers import ai_mode
|
|||
from app.services import ai_mode_service
|
||||
|
||||
|
||||
def _stub_client(create):
|
||||
client = Mock()
|
||||
client.chat.completions.create = create
|
||||
return client
|
||||
|
||||
|
||||
class CitationContractTests(unittest.TestCase):
|
||||
"""The step that makes a hallucinated citation impossible rather than rare."""
|
||||
|
||||
|
|
@ -133,10 +127,8 @@ class _AiModeBase(unittest.TestCase):
|
|||
|
||||
def reply_with(self, text):
|
||||
"""Stub the model, so the test is about the server's half of the contract."""
|
||||
response = type("R", (), {"choices": [type("C", (), {
|
||||
"message": type("M", (), {"content": text})()})()]})()
|
||||
return patch("app.services.ai_service.get_async_client",
|
||||
return_value=_stub_client(AsyncMock(return_value=response)))
|
||||
return patch("app.services.ai_service.achat",
|
||||
new=AsyncMock(return_value=text))
|
||||
|
||||
def ask(self, message, conversation_id=None):
|
||||
if conversation_id is None:
|
||||
|
|
@ -185,8 +177,8 @@ class AiModeRouteTests(_AiModeBase):
|
|||
|
||||
def test_a_model_failure_is_reported_not_stored(self):
|
||||
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||
with patch("app.services.ai_service.get_async_client",
|
||||
return_value=_stub_client(AsyncMock(side_effect=RuntimeError("down")))):
|
||||
with patch("app.services.ai_service.achat",
|
||||
new=AsyncMock(side_effect=RuntimeError("down"))):
|
||||
response = self.client.post(f'/ai/conversations/{conversation_id}/messages',
|
||||
json={'message': 'febrile seizure'})
|
||||
self.assertEqual(response.status_code, 502)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ class ArticleAiTests(unittest.TestCase):
|
|||
|
||||
def test_draft_task_creates_and_refines_without_publishing(self):
|
||||
self.redis.set.return_value = True
|
||||
ai = patch('app.services.ai_service.get_client').start().return_value.chat.completions.create
|
||||
ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(DRAFT_RESPONSE)))])
|
||||
ai = patch('app.services.ai_service.chat').start()
|
||||
ai.return_value = json.dumps(DRAFT_RESPONSE)
|
||||
with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \
|
||||
patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)):
|
||||
generate_article_draft('job-1', 3, 'Neonatal jaundice', '')
|
||||
|
|
@ -92,7 +92,7 @@ class ArticleAiTests(unittest.TestCase):
|
|||
self.assertEqual(refreshed.slug, 'ai-draft-topic')
|
||||
# Invalid model section ids are replaced with valid hex ids.
|
||||
bad = dict(DRAFT_RESPONSE, sections=[{'id': 'bad-id', 'slug': 'bad', 'title': 'Bad', 'content': ''}])
|
||||
ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(bad)))])
|
||||
ai.return_value = json.dumps(bad)
|
||||
generate_article_draft('job-3', 3, 'Neonatal jaundice', 'Again', article.id)
|
||||
fixed = self.bank.db.query(Article).filter_by(id=article.id).one()
|
||||
self.assertTrue(all(re.fullmatch(r'[0-9a-f]{32}', s['id']) for s in fixed.sections), fixed.sections)
|
||||
|
|
|
|||
220
backend/tests/test_login_links.py
Normal file
220
backend/tests/test_login_links.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Signing in with a link sent by email.
|
||||
|
||||
The properties worth pinning are the ones that are invisible when they break: a
|
||||
stranger's address gets the same answer as a member's, in the same time; a link
|
||||
works once and not after that; and the limiter counts requests for an address
|
||||
that has no account exactly as it counts the others, or the 429 becomes the
|
||||
answer the identical message was there to withhold.
|
||||
|
||||
Disposable SQLite, a Mock for Redis, and nothing leaves the process.
|
||||
"""
|
||||
import hashlib
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from types import ModuleType
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.login_link import LoginLink
|
||||
from app.routers import login_link
|
||||
from app.services import email_service, login_links
|
||||
|
||||
|
||||
class MemoryRedis:
|
||||
def __init__(self): self.values = {}
|
||||
def get(self, key): return self.values.get(key)
|
||||
def set(self, key, value): self.values[key] = value
|
||||
def incr(self, key):
|
||||
self.values[key] = int(self.values.get(key, 0)) + 1
|
||||
return self.values[key]
|
||||
def expire(self, *args): return True
|
||||
|
||||
|
||||
class LoginLinkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.bank.owner.email = "owner@example.com"
|
||||
self.bank.db.commit()
|
||||
self.client = self.bank.client
|
||||
self.client.app.include_router(login_link.router, prefix="/auth")
|
||||
|
||||
self.redis = MemoryRedis()
|
||||
module = ModuleType("redis")
|
||||
module.from_url = lambda *args, **kwargs: self.redis
|
||||
modules = patch.dict(sys.modules, {"redis": module})
|
||||
modules.start()
|
||||
self.addCleanup(modules.stop)
|
||||
|
||||
# Every reply is paced to a floor; the pacing itself has its own test,
|
||||
# and the rest of the suite has no reason to wait for it.
|
||||
floor = patch.object(login_link, "MIN_SECONDS", 0.01)
|
||||
floor.start()
|
||||
self.addCleanup(floor.stop)
|
||||
|
||||
sent = patch.object(email_service, "send_login_link_email", new_callable=AsyncMock)
|
||||
self.sent = sent.start()
|
||||
self.addCleanup(sent.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def ask(self, email="owner@example.com"):
|
||||
return self.client.post("/auth/login-link", json={"email": email})
|
||||
|
||||
def token_for(self, email="owner@example.com"):
|
||||
self.sent.reset_mock()
|
||||
self.assertEqual(self.ask(email).status_code, 200)
|
||||
self.sent.assert_awaited_once()
|
||||
return self.sent.await_args.args[2]
|
||||
|
||||
def spend(self, token):
|
||||
return self.client.post("/auth/login-link/consume", json={"token": token})
|
||||
|
||||
# ── the answer gives nothing away ──────────────────────────────────
|
||||
|
||||
def test_a_stranger_gets_the_member_s_answer_word_for_word(self):
|
||||
known = self.ask()
|
||||
unknown = self.ask("nobody@example.com")
|
||||
self.assertEqual(known.status_code, unknown.status_code)
|
||||
self.assertEqual(known.json(), unknown.json())
|
||||
self.assertEqual(known.json(), {"message": login_link.SENT})
|
||||
# And the only difference behind it is one that never reaches the caller.
|
||||
self.assertEqual(self.bank.db.query(LoginLink).count(), 1)
|
||||
|
||||
def test_both_answers_are_held_to_the_same_floor(self):
|
||||
# The real constant, because the point of the test is the value of it.
|
||||
with patch.object(login_link, "MIN_SECONDS", 0.3):
|
||||
times = []
|
||||
for address in ("owner@example.com", "nobody@example.com"):
|
||||
started = time.perf_counter()
|
||||
self.ask(address)
|
||||
times.append(time.perf_counter() - started)
|
||||
for elapsed in times:
|
||||
self.assertGreaterEqual(elapsed, 0.3)
|
||||
self.assertLess(abs(times[0] - times[1]), 0.15)
|
||||
|
||||
def test_the_limiter_counts_a_stranger_s_address_too(self):
|
||||
# Otherwise the 429 arrives only for addresses that exist, and the
|
||||
# identical message above has been undone by the thing protecting it.
|
||||
for _ in range(login_link.PER_ADDRESS_CALLS):
|
||||
self.assertEqual(self.ask("nobody@example.com").status_code, 200)
|
||||
self.assertEqual(self.ask("nobody@example.com").status_code, 429)
|
||||
self.assertEqual(self.ask().status_code, 200)
|
||||
|
||||
def test_the_address_limit_bites_on_the_fourth_request(self):
|
||||
for _ in range(login_link.PER_ADDRESS_CALLS):
|
||||
self.assertEqual(self.ask().status_code, 200)
|
||||
refused = self.ask()
|
||||
self.assertEqual(refused.status_code, 429)
|
||||
self.assertIn("Too many sign-in links", refused.json()["detail"])
|
||||
# Nothing was issued on the refused attempt.
|
||||
self.assertEqual(self.bank.db.query(LoginLink).count(), login_link.PER_ADDRESS_CALLS)
|
||||
|
||||
def test_the_network_limit_is_the_looser_of_the_two(self):
|
||||
self.assertGreater(login_link.PER_IP_CALLS, login_link.PER_ADDRESS_CALLS)
|
||||
# Ask from one address until the per-network limit is the one that stops
|
||||
# it — a different address each time, so the tighter limit cannot.
|
||||
for n in range(login_link.PER_IP_CALLS):
|
||||
self.assertEqual(self.ask(f"person{n}@example.com").status_code, 200)
|
||||
refused = self.ask(f"person{login_link.PER_IP_CALLS}@example.com")
|
||||
self.assertEqual(refused.status_code, 429)
|
||||
self.assertIn("from this network", refused.json()["detail"])
|
||||
|
||||
# ── the link itself ────────────────────────────────────────────────
|
||||
|
||||
def test_a_link_signs_you_in_once_and_never_again(self):
|
||||
token = self.token_for()
|
||||
first = self.spend(token)
|
||||
self.assertEqual(first.status_code, 200, first.text)
|
||||
self.assertTrue(first.json()["access_token"])
|
||||
second = self.spend(token)
|
||||
self.assertEqual(second.status_code, 400)
|
||||
self.assertIn("no longer valid", second.json()["detail"])
|
||||
|
||||
def test_an_expired_link_is_refused(self):
|
||||
token = self.token_for()
|
||||
row = self.bank.db.query(LoginLink).one()
|
||||
row.expires_at = datetime.utcnow() - timedelta(seconds=1)
|
||||
self.bank.db.commit()
|
||||
self.assertEqual(self.spend(token).status_code, 400)
|
||||
# Refused without being marked spent: it was never used, and the row
|
||||
# says so.
|
||||
self.bank.db.refresh(row)
|
||||
self.assertIsNone(row.consumed_at)
|
||||
|
||||
def test_fifteen_minutes_and_not_an_hour(self):
|
||||
self.token_for()
|
||||
row = self.bank.db.query(LoginLink).one()
|
||||
self.assertAlmostEqual((row.expires_at - row.created_at).total_seconds(), 15 * 60, delta=5)
|
||||
|
||||
def test_a_second_request_retires_the_first_link(self):
|
||||
stale = self.token_for()
|
||||
fresh = self.token_for()
|
||||
self.assertEqual(self.spend(stale).status_code, 400)
|
||||
self.assertEqual(self.spend(fresh).status_code, 200)
|
||||
|
||||
def test_a_token_that_was_never_issued_reads_the_same_as_a_spent_one(self):
|
||||
token = self.token_for()
|
||||
self.spend(token)
|
||||
invented = self.spend("not-a-token-anybody-issued")
|
||||
spent = self.spend(token)
|
||||
self.assertEqual(invented.status_code, spent.status_code)
|
||||
self.assertEqual(invented.json(), spent.json())
|
||||
|
||||
def test_the_table_holds_a_fingerprint_rather_than_the_token(self):
|
||||
token = self.token_for()
|
||||
row = self.bank.db.query(LoginLink).one()
|
||||
self.assertNotIn(token, str(row.token_hash))
|
||||
self.assertEqual(row.token_hash, hashlib.sha256(token.encode()).hexdigest())
|
||||
|
||||
def test_the_mail_carries_the_address_the_name_and_the_token(self):
|
||||
token = self.token_for()
|
||||
self.assertEqual(self.sent.await_args.args[:2], ("owner@example.com", "Owner"))
|
||||
self.assertTrue(token)
|
||||
|
||||
# ── the rules the password form already keeps ──────────────────────
|
||||
|
||||
def test_an_unverified_account_is_turned_away_as_it_is_at_the_password_form(self):
|
||||
self.bank.db.add(EmailVerification(user_id=self.bank.owner.id, token="verification-token",
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1), verified_at=None))
|
||||
self.bank.db.commit()
|
||||
refused = self.spend(self.token_for())
|
||||
self.assertEqual(refused.status_code, 403)
|
||||
self.assertIn("Email not verified", refused.json()["detail"])
|
||||
|
||||
def test_sso_only_closes_this_door_as_well_as_the_password_one(self):
|
||||
self.redis.values["settings:sso_only"] = "true"
|
||||
refused = self.ask()
|
||||
self.assertEqual(refused.status_code, 403)
|
||||
self.assertIn("Please use SSO", refused.json()["detail"])
|
||||
self.assertEqual(self.bank.db.query(LoginLink).count(), 0)
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
"""The claim is a single UPDATE, so two holders of one token cannot both win."""
|
||||
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.addCleanup(self.bank.tearDown)
|
||||
|
||||
def test_only_one_of_two_simultaneous_claims_succeeds(self):
|
||||
token = login_links.issue(self.bank.db, self.bank.owner)
|
||||
self.assertIsNotNone(login_links.consume(self.bank.db, token))
|
||||
self.assertIsNone(login_links.consume(self.bank.db, token))
|
||||
|
||||
def test_rows_long_past_their_expiry_are_swept_on_the_next_request(self):
|
||||
login_links.issue(self.bank.db, self.bank.owner)
|
||||
stale = self.bank.db.query(LoginLink).one()
|
||||
stale.expires_at = datetime.utcnow() - timedelta(days=3)
|
||||
self.bank.db.commit()
|
||||
login_links.issue(self.bank.db, self.bank.owner)
|
||||
self.assertEqual(self.bank.db.query(LoginLink).count(), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
|
|
@ -46,9 +45,8 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.can_see = patch.object(teach.vision_service, 'can_see', return_value=True).start()
|
||||
self.find_similar = teach._find_similar_questions
|
||||
self.similar = patch.object(teach, '_find_similar_questions', return_value=[]).start()
|
||||
self.ai = AsyncMock(return_value=SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content='Tutor reply\n> Follow up'))]))
|
||||
patch('app.services.ai_service.get_async_client').start().return_value.chat.completions.create = self.ai
|
||||
self.ai = AsyncMock(return_value='Tutor reply\n> Follow up')
|
||||
patch('app.services.ai_service.achat', new=self.ai).start()
|
||||
self.embedding = patch('app.services.embedding_service.embed_question').start()
|
||||
|
||||
def tearDown(self):
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.orm import sessionmaker
|
|||
from app.config import settings
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.services import vision_service
|
||||
from app.services.ai_service import ProxyError
|
||||
from app.services.vision_service import Image, VisionUnavailable
|
||||
|
||||
|
||||
|
|
@ -26,9 +27,10 @@ def catalogue_rows(*pairs):
|
|||
for name, says in pairs]}
|
||||
|
||||
|
||||
def stub_client(reply="A chest radiograph with a right lower lobe opacity."):
|
||||
create = Mock(return_value=Mock(choices=[Mock(message=Mock(content=reply))]))
|
||||
return Mock(**{"chat.completions.create": create}), create
|
||||
def stub_chat(reply="A chest radiograph with a right lower lobe opacity."):
|
||||
"""`ai_service.chat` hands back the message text itself, so the stub is the
|
||||
text — there is no envelope left to imitate."""
|
||||
return Mock(return_value=reply)
|
||||
|
||||
|
||||
class VisionFallbackTests(unittest.TestCase):
|
||||
|
|
@ -65,8 +67,8 @@ class VisionFallbackTests(unittest.TestCase):
|
|||
|
||||
def test_a_model_that_can_see_is_handed_the_image_itself(self):
|
||||
self.catalogue(("seeing-model", True))
|
||||
client, create = stub_client()
|
||||
with patch.object(vision_service, "get_client", return_value=client):
|
||||
create = stub_chat()
|
||||
with patch.object(vision_service, "chat", create):
|
||||
parts, handoff = vision_service.image_context(
|
||||
self.db, [self.image], model_id="seeing-model")
|
||||
|
||||
|
|
@ -79,8 +81,8 @@ class VisionFallbackTests(unittest.TestCase):
|
|||
def test_a_model_that_cannot_see_gets_the_tool_model_s_description(self):
|
||||
self.catalogue(("blind-model", False), ("tool-vision", True))
|
||||
self.tool_model()
|
||||
client, create = stub_client()
|
||||
with patch.object(vision_service, "get_client", return_value=client):
|
||||
create = stub_chat()
|
||||
with patch.object(vision_service, "chat", create):
|
||||
parts, handoff = vision_service.image_context(
|
||||
self.db, [self.image], model_id="blind-model", context="A 4-year-old…")
|
||||
|
||||
|
|
@ -109,8 +111,8 @@ class VisionFallbackTests(unittest.TestCase):
|
|||
def test_a_tool_model_that_cannot_see_either_is_refused_before_it_is_called(self):
|
||||
self.catalogue(("blind-model", False), ("also-blind", False))
|
||||
self.tool_model("also-blind")
|
||||
client, create = stub_client()
|
||||
with patch.object(vision_service, "get_client", return_value=client):
|
||||
create = stub_chat()
|
||||
with patch.object(vision_service, "chat", create):
|
||||
with self.assertRaises(VisionUnavailable) as raised:
|
||||
vision_service.image_context(self.db, [self.image], model_id="blind-model")
|
||||
self.assertIn("also-blind", str(raised.exception))
|
||||
|
|
@ -119,9 +121,8 @@ class VisionFallbackTests(unittest.TestCase):
|
|||
def test_a_failing_tool_model_is_an_error_rather_than_a_text_only_answer(self):
|
||||
self.catalogue(("blind-model", False), ("tool-vision", True))
|
||||
self.tool_model()
|
||||
client = Mock()
|
||||
client.chat.completions.create.side_effect = RuntimeError("upstream 500")
|
||||
with patch.object(vision_service, "get_client", return_value=client):
|
||||
create = Mock(side_effect=RuntimeError("upstream 500"))
|
||||
with patch.object(vision_service, "chat", create):
|
||||
with self.assertRaises(VisionUnavailable) as raised:
|
||||
vision_service.image_context(self.db, [self.image], model_id="blind-model")
|
||||
self.assertIn("upstream 500", str(raised.exception))
|
||||
|
|
@ -162,37 +163,47 @@ class CapabilityLookupTests(unittest.TestCase):
|
|||
|
||||
def test_a_model_the_catalogue_says_nothing_about_is_probed_once(self):
|
||||
response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None))))
|
||||
client, create = stub_client("ok")
|
||||
create = stub_chat("ok")
|
||||
with patch("httpx.get", return_value=response), \
|
||||
patch.object(vision_service, "get_client", return_value=client):
|
||||
patch.object(vision_service, "chat", create):
|
||||
self.assertTrue(vision_service.can_see("quiet"))
|
||||
self.assertTrue(vision_service.can_see("quiet"))
|
||||
create.assert_called_once()
|
||||
self.assertEqual(create.call_args.kwargs["max_tokens"], 1)
|
||||
|
||||
def test_a_refused_probe_is_remembered_and_a_broken_one_is_not(self):
|
||||
class Refused(Exception):
|
||||
status_code = 400
|
||||
|
||||
# The real error the proxy layer raises, not a stand-in: the probe sorts
|
||||
# refusals by `status_code`, and that ProxyError carries one is the
|
||||
# whole reason it exists.
|
||||
response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None))))
|
||||
client = Mock()
|
||||
client.chat.completions.create.side_effect = Refused("image input not supported")
|
||||
create = Mock(side_effect=ProxyError(400, "image input not supported"))
|
||||
with patch("httpx.get", return_value=response), \
|
||||
patch.object(vision_service, "get_client", return_value=client):
|
||||
patch.object(vision_service, "chat", create):
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
client.chat.completions.create.assert_called_once()
|
||||
create.assert_called_once()
|
||||
|
||||
# A timeout says nothing about the model, so nothing is kept: asking
|
||||
# again asks the model again.
|
||||
vision_service._probes.clear()
|
||||
client.chat.completions.create.reset_mock()
|
||||
client.chat.completions.create.side_effect = TimeoutError("proxy is down")
|
||||
create.reset_mock()
|
||||
create.side_effect = TimeoutError("proxy is down")
|
||||
with patch("httpx.get", return_value=response), \
|
||||
patch.object(vision_service, "get_client", return_value=client):
|
||||
patch.object(vision_service, "chat", create):
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
self.assertEqual(client.chat.completions.create.call_count, 2)
|
||||
self.assertEqual(create.call_count, 2)
|
||||
|
||||
def test_a_5xx_probe_is_not_remembered_either(self):
|
||||
# Same shape as a refusal, opposite meaning: the proxy broke, the model
|
||||
# said nothing, so the verdict must not be cached.
|
||||
response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None))))
|
||||
create = Mock(side_effect=ProxyError(503, "upstream unavailable"))
|
||||
with patch("httpx.get", return_value=response), \
|
||||
patch.object(vision_service, "chat", create):
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
self.assertFalse(vision_service.can_see("quiet"))
|
||||
self.assertEqual(create.call_count, 2)
|
||||
|
||||
|
||||
class TutorFigureTests(unittest.TestCase):
|
||||
|
|
@ -232,8 +243,8 @@ class TutorFigureTests(unittest.TestCase):
|
|||
self.privacy.db.add(AIModelConfig(name="tool-vision", model_id="tool-vision",
|
||||
task="tool", is_active=True, is_default=True))
|
||||
self.privacy.db.commit()
|
||||
client, create = stub_client("A frontal chest radiograph.")
|
||||
with patch.object(vision_service, "get_client", return_value=client):
|
||||
create = stub_chat("A frontal chest radiograph.")
|
||||
with patch.object(vision_service, "chat", create):
|
||||
response = self.chat()
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const CoursesPage = lazyPage(() => import('./pages/CoursesPage'))
|
|||
const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage'))
|
||||
const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage'))
|
||||
const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage'))
|
||||
const SignInLinkPage = lazyPage(() => import('./pages/SignInLinkPage'))
|
||||
|
||||
function LoadingFallback() {
|
||||
return <div className="loading"><div className="spinner" /></div>
|
||||
|
|
@ -201,6 +202,7 @@ function AppRoutes() {
|
|||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
||||
<Route path="/signin-link" element={<SignInLinkPage />} />
|
||||
<Route path="/share/:token" element={<PublicQuizPage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ afterEach(() => { document.getElementById('cap-widget-script')?.remove() })
|
|||
function mount(Component) {
|
||||
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
|
||||
}
|
||||
async function submitLogin() {
|
||||
async function submitLogin({ revealPassword = false } = {}) {
|
||||
// The standalone page leads with a link by email; the password is behind a
|
||||
// second option, and the landing modal still asks for it outright.
|
||||
if (revealPassword) await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
|
||||
const form = screen.getByRole('form', { name: 'Sign in' })
|
||||
expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled()
|
||||
await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test')
|
||||
|
|
@ -35,7 +38,7 @@ async function submitLogin() {
|
|||
|
||||
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
|
||||
mount(LoginPage)
|
||||
await submitLogin()
|
||||
await submitLogin({ revealPassword: true })
|
||||
// Signing in is not a form a stranger can spam into existence — there is an
|
||||
// account behind it and a rate limit in front of it.
|
||||
expect(document.querySelector('cap-widget')).toBeNull()
|
||||
|
|
|
|||
133
frontend/src/pages/LoginLink.test.jsx
Normal file
133
frontend/src/pages/LoginLink.test.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
import LoginPage from './LoginPage'
|
||||
import SignInLinkPage from './SignInLinkPage'
|
||||
import { AuthProvider } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
const SENT = 'If that address has an account, a sign-in link is on its way.'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
api.get.mockImplementation(url => Promise.resolve({
|
||||
data: url === '/auth/sso/config' ? { sso_enabled: false }
|
||||
: url === '/auth/signup-policy' ? { registration_open: true }
|
||||
: { id: 1, name: 'Test', role: 'user' },
|
||||
}))
|
||||
api.post.mockResolvedValue({ data: { message: SENT } })
|
||||
})
|
||||
|
||||
function mountLogin() {
|
||||
render(<MemoryRouter initialEntries={['/login']}><AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<div>Signed in successfully</div>} />
|
||||
</Routes>
|
||||
</AuthProvider></MemoryRouter>)
|
||||
}
|
||||
|
||||
function mountLink(search = '?token=synthetic-link-token', strict = false) {
|
||||
const tree = (
|
||||
<MemoryRouter initialEntries={[`/signin-link${search}`]}><AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/signin-link" element={<SignInLinkPage />} />
|
||||
<Route path="/" element={<div>Signed in successfully</div>} />
|
||||
</Routes>
|
||||
</AuthProvider></MemoryRouter>
|
||||
)
|
||||
render(strict ? <StrictMode>{tree}</StrictMode> : tree)
|
||||
}
|
||||
|
||||
it('offers a link by email first and asks for no password to send one', async () => {
|
||||
mountLogin()
|
||||
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with email' }))
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login-link', { email: 'owner@example.test' })
|
||||
expect(await screen.findByText(SENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('repeats what the server said rather than claiming the mail was sent', async () => {
|
||||
// The message is the one thing that must read the same for an address with
|
||||
// an account and one without, so the page is not allowed a copy of its own.
|
||||
api.post.mockResolvedValue({ data: { message: 'A differently worded but equally uncommitted answer.' } })
|
||||
mountLogin()
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'nobody@example.test')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with email' }))
|
||||
expect(await screen.findByText('A differently worded but equally uncommitted answer.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/we sent|no account|not registered/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the reason when a request is refused', async () => {
|
||||
api.post.mockRejectedValue({ response: { status: 429, data: { detail: 'Too many sign-in links requested for that address. Please wait a few minutes.' } } })
|
||||
mountLogin()
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with email' }))
|
||||
expect(await screen.findByText(/Too many sign-in links/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a password one click away for whoever has one', async () => {
|
||||
api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } })
|
||||
mountLogin()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test')
|
||||
await userEvent.type(screen.getByLabelText('Password'), 'synthetic-password')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
|
||||
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { email: 'owner@example.test', password: 'synthetic-password' })
|
||||
expect(api.post).not.toHaveBeenCalledWith('/auth/login-link', expect.anything())
|
||||
})
|
||||
|
||||
it('leaves single sign-on the first thing on the page', async () => {
|
||||
api.get.mockImplementation(url => Promise.resolve({
|
||||
data: url === '/auth/sso/config' ? { sso_enabled: true, sso_only: false, provider_name: 'Test SSO' }
|
||||
: url === '/auth/signup-policy' ? { registration_open: true }
|
||||
: { id: 1, name: 'Test', role: 'user' },
|
||||
}))
|
||||
mountLogin()
|
||||
const sso = await screen.findByRole('link', { name: 'Sign in with Test SSO' })
|
||||
const email = screen.getByRole('button', { name: 'Continue with email' })
|
||||
expect(sso.compareDocumentPosition(email) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers registration only while the site is open to it', async () => {
|
||||
api.get.mockImplementation(url => Promise.resolve({
|
||||
data: url === '/auth/sso/config' ? { sso_enabled: false }
|
||||
: url === '/auth/signup-policy' ? { registration_open: false }
|
||||
: { id: 1, name: 'Test', role: 'user' },
|
||||
}))
|
||||
mountLogin()
|
||||
expect(await screen.findByRole('button', { name: 'Continue with email' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('link', { name: 'Sign up' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('spends a followed link once and signs the reader in', async () => {
|
||||
api.post.mockResolvedValue({ data: { access_token: 'synthetic-link-session' } })
|
||||
// Rendered strictly, because that is what development does, and a link spent
|
||||
// twice is a link that fails on arrival.
|
||||
mountLink('?token=synthetic-link-token', true)
|
||||
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenCalledTimes(1)
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login-link/consume', { token: 'synthetic-link-token' })
|
||||
expect(localStorage.getItem('token')).toBe('synthetic-link-session')
|
||||
})
|
||||
|
||||
it('says a spent or expired link is no good and offers another', async () => {
|
||||
api.post.mockRejectedValue({ response: { status: 400, data: { detail: 'That sign-in link is no longer valid. If you asked more than once, open the most recent email, or request a new link.' } } })
|
||||
mountLink()
|
||||
expect(await screen.findByText(/no longer valid/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Back to sign in' })).toHaveAttribute('href', '/login')
|
||||
})
|
||||
|
||||
it('does not call the API for a link with no token at all', async () => {
|
||||
mountLink('')
|
||||
expect(await screen.findByText(/incomplete/)).toBeInTheDocument()
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -3,15 +3,27 @@ import { useNavigate, Link } from 'react-router-dom'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
// Said by the server, and repeated here only if it says nothing. The wording
|
||||
// is load-bearing — it must not admit whether the address has an account — so
|
||||
// it lives in one place and this is not that place.
|
||||
const LINK_SENT = 'If that address has an account, a sign-in link is on its way.'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
// A password is the second way in, not the first: most people do not have
|
||||
// one worth remembering, and the ones who do are one click from it.
|
||||
const [withPassword, setWithPassword] = useState(false)
|
||||
const [linkSent, setLinkSent] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unverified, setUnverified] = useState(false)
|
||||
const [resendSent, setResendSent] = useState(false)
|
||||
const [resending, setResending] = useState(false)
|
||||
const [ssoConfig, setSsoConfig] = useState(null)
|
||||
// Null until asked, and treated as open while unknown: a slow answer must
|
||||
// not hide a way in that exists.
|
||||
const [policy, setPolicy] = useState(null)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
|
|
@ -19,24 +31,34 @@ export default function LoginPage() {
|
|||
|
||||
useEffect(() => {
|
||||
api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {})
|
||||
api.get('/auth/signup-policy').then(r => setPolicy(r.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const failed = (err, fallback) => {
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') setError(detail)
|
||||
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
|
||||
else setError(fallback)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setUnverified(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
if (withPassword) {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} else {
|
||||
const res = await api.post('/auth/login-link', { email })
|
||||
setLinkSent(res.data?.message || LINK_SENT)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status === 403) {
|
||||
if (withPassword && err.response?.status === 403) {
|
||||
setUnverified(true)
|
||||
} else {
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') setError(detail)
|
||||
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
|
||||
else setError('Login failed')
|
||||
failed(err, withPassword ? 'Login failed' : 'Could not send a sign-in link. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -55,6 +77,29 @@ export default function LoginPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const usePassword = () => { setLinkSent(''); setError(''); setWithPassword(true) }
|
||||
|
||||
if (linkSent) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: 16 }}>📧</div>
|
||||
<h1 style={{ marginBottom: 12 }}>Check your email</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: 8 }}>{linkSent}</p>
|
||||
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
||||
The link works once and expires in 15 minutes. Open it on any device.
|
||||
</p>
|
||||
<button className="btn btn-secondary" onClick={() => { setLinkSent(''); setPassword('') }}>
|
||||
Use a different address
|
||||
</button>
|
||||
<div className="auth-link" style={{ marginTop: 16 }}>
|
||||
Have a password? <button onClick={usePassword} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Sign in with it instead</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
|
|
@ -96,20 +141,41 @@ export default function LoginPage() {
|
|||
<label htmlFor="login-email">Email</label>
|
||||
<input id="login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input id="login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
{withPassword && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input id="login-password" type="password" autoComplete="current-password" autoFocus value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
{withPassword
|
||||
? (loading ? 'Signing in...' : 'Sign In')
|
||||
: (loading ? 'Sending...' : 'Continue with email')}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-link">
|
||||
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||
</div>
|
||||
<div className="auth-link">
|
||||
Don't have an account? <Link to="/register">Sign up</Link>
|
||||
</div>
|
||||
{withPassword ? (
|
||||
<div className="auth-link" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<button onClick={() => setWithPassword(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0, fontSize: '0.85rem' }}>
|
||||
Email me a link instead
|
||||
</button>
|
||||
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-link">
|
||||
{/* No mail to wait for if you already have a password. */}
|
||||
<button onClick={usePassword} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0, fontSize: '0.85rem' }}>
|
||||
Sign in with a password instead
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Only where there is a door. Registration can be turned off
|
||||
site-wide, and this offered a locked one — the only way to find
|
||||
out was to fill the form in and be refused. */}
|
||||
{policy?.registration_open !== false && (
|
||||
<div className="auth-link">
|
||||
Don't have an account? <Link to="/register">Sign up</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
54
frontend/src/pages/SignInLinkPage.jsx
Normal file
54
frontend/src/pages/SignInLinkPage.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
/**
|
||||
* Where a sign-in link lands.
|
||||
*
|
||||
* The link points here rather than straight at the API because spending it is
|
||||
* a POST made from script: mail gateways and preview panes fetch every URL in
|
||||
* a message, and a single-use token spent by a scanner is a dead link by the
|
||||
* time its owner clicks it.
|
||||
*/
|
||||
export default function SignInLinkPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const navigate = useNavigate()
|
||||
const { loginWithToken } = useAuth()
|
||||
const [error, setError] = useState('')
|
||||
// Asking twice spends the link and then fails on its own second request.
|
||||
// React deliberately runs an effect twice in development, and state set
|
||||
// inside the first run is not there yet when the second begins — so the
|
||||
// guard has to be a ref.
|
||||
const asked = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (asked.current) return
|
||||
asked.current = true
|
||||
if (!token) {
|
||||
setError('That link is incomplete. Request a new one.')
|
||||
return
|
||||
}
|
||||
api.post('/auth/login-link/consume', { token })
|
||||
.then(res => loginWithToken(res.data.access_token))
|
||||
// Replaced rather than pushed: the token is in this URL, and there is no
|
||||
// reason for the back button to bring it back.
|
||||
.then(() => navigate('/', { replace: true }))
|
||||
.catch(err => setError(err.response?.data?.detail
|
||||
|| 'That sign-in link could not be used. Request a new one.'))
|
||||
}, [token])
|
||||
|
||||
if (!error) return <div className="loading"><div className="spinner"></div> Signing you in...</div>
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: 12 }}>🔗</div>
|
||||
<h1 style={{ marginBottom: 12 }}>This link didn't work</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: 24 }}>{error}</p>
|
||||
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Back to sign in</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue