Implement Google sign-in with Supabase and add related functionality
Browse files- .env.example +8 -0
- README.md +35 -0
- app/auth.py +92 -0
- app/main.py +45 -8
- static/index.html +48 -7
- static/privacy.html +41 -0
- tests/conftest.py +4 -0
- tests/test_scoring.py +49 -0
.env.example
CHANGED
|
@@ -8,3 +8,11 @@ SAWTAK_API_KEY=
|
|
| 8 |
# Optional: a specific voice id from the Sawtak catalog.
|
| 9 |
# Empty = the most liked ready Palestinian voice.
|
| 10 |
SAWTAK_VOICE_ID=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Optional: a specific voice id from the Sawtak catalog.
|
| 9 |
# Empty = the most liked ready Palestinian voice.
|
| 10 |
SAWTAK_VOICE_ID=
|
| 11 |
+
|
| 12 |
+
# Google sign-in through Supabase (Project Settings -> API Keys). Both values are public: the page gets them too.
|
| 13 |
+
# Empty = no sign-in, the service is open. On Hugging Face these can be plain Space *variables*.
|
| 14 |
+
SUPABASE_URL=
|
| 15 |
+
SUPABASE_PUBLISHABLE_KEY=
|
| 16 |
+
|
| 17 |
+
# Optional: the only Google accounts let in, separated by commas or spaces. Empty = any Google account.
|
| 18 |
+
ALLOWED_EMAILS=
|
README.md
CHANGED
|
@@ -68,6 +68,41 @@ The teacher only needs that link. Free Spaces sleep after inactivity, so the fir
|
|
| 68 |
|
| 69 |
`docker build -t ihki . && docker run -p 7860:7860 ihki` runs the same image anywhere.
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
## Fine-tuning
|
| 72 |
|
| 73 |
The base model was not trained on Palestinian Arabic, and it has heard few Hebrew-accented pharyngeals. Expect the zero-shot scores to be noisy until it is fine-tuned on matched data.
|
|
|
|
| 68 |
|
| 69 |
`docker build -t ihki . && docker run -p 7860:7860 ihki` runs the same image anywhere.
|
| 70 |
|
| 71 |
+
## Sign-in and progress (Supabase)
|
| 72 |
+
|
| 73 |
+
Optional. With `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` set, the page asks for a Google sign-in (through Supabase Auth) and the practice endpoints refuse calls without a session. `ALLOWED_EMAILS` limits entry to the listed accounts. Without the two values the service is open.
|
| 74 |
+
|
| 75 |
+
Each scored recording is added to the table `attempts`, written with the student's own token so row-level security ties the row to them. Create it once in the Supabase SQL editor:
|
| 76 |
+
|
| 77 |
+
```sql
|
| 78 |
+
create table public.attempts (
|
| 79 |
+
id bigint generated always as identity primary key,
|
| 80 |
+
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
|
| 81 |
+
cat text not null, -- the topic id, e.g. l1-verbs
|
| 82 |
+
phrase text not null, -- the Hebrew prompt
|
| 83 |
+
score int not null, -- 0-100
|
| 84 |
+
unrelated boolean not null default false, -- "different sentence?"
|
| 85 |
+
created_at timestamptz not null default now()
|
| 86 |
+
);
|
| 87 |
+
create index attempts_user_time on public.attempts (user_id, created_at desc);
|
| 88 |
+
|
| 89 |
+
alter table public.attempts enable row level security;
|
| 90 |
+
grant select, insert on public.attempts to authenticated;
|
| 91 |
+
create policy "students add their own attempts" on public.attempts
|
| 92 |
+
for insert to authenticated with check (user_id = auth.uid());
|
| 93 |
+
create policy "students read their own attempts" on public.attempts
|
| 94 |
+
for select to authenticated using (user_id = auth.uid());
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
The teacher's view, also in the SQL editor (it runs as the owner, so it sees every student):
|
| 98 |
+
|
| 99 |
+
```sql
|
| 100 |
+
select u.email, a.cat, count(distinct a.phrase) as phrases, count(*) as tries,
|
| 101 |
+
round(avg(a.score)) as average, max(a.created_at) as last_practice
|
| 102 |
+
from public.attempts a join auth.users u on u.id = a.user_id
|
| 103 |
+
group by u.email, a.cat order by u.email, a.cat;
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
## Fine-tuning
|
| 107 |
|
| 108 |
The base model was not trained on Palestinian Arabic, and it has heard few Hebrew-accented pharyngeals. Expect the zero-shot scores to be noisy until it is fine-tuned on matched data.
|
app/auth.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sign-in: the page logs in with Google through Supabase and sends the session token with each call.
|
| 2 |
+
|
| 3 |
+
The token is checked by asking Supabase who it belongs to (GET /auth/v1/user), so no signing key is
|
| 4 |
+
kept here and a session revoked in Supabase stops working within CACHE_TTL.
|
| 5 |
+
|
| 6 |
+
Progress: each scored recording is added to the Supabase table `attempts` with the student's own
|
| 7 |
+
token, so row-level security ties the row to them (the table's SQL is in the README).
|
| 8 |
+
|
| 9 |
+
Env: SUPABASE_URL (https://<project-ref>.supabase.co)
|
| 10 |
+
SUPABASE_PUBLISHABLE_KEY (the publishable/anon key; it is public and also goes to the page)
|
| 11 |
+
ALLOWED_EMAILS (optional: the only accounts let in, separated by commas or spaces)
|
| 12 |
+
Without them the service is open, as before.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import threading
|
| 21 |
+
import time
|
| 22 |
+
import urllib.error
|
| 23 |
+
import urllib.request
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger("ihki.auth")
|
| 26 |
+
TIMEOUT = 10
|
| 27 |
+
CACHE_TTL = 300 # seconds a checked token is trusted without asking again
|
| 28 |
+
CACHE_MAX = 2000
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AuthUnavailable(RuntimeError):
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SupabaseAuth:
|
| 36 |
+
def __init__(self, url: str, key: str, allowed: frozenset[str] = frozenset()):
|
| 37 |
+
self.url, self.key = url.rstrip("/"), key
|
| 38 |
+
self.allowed = allowed # lower-case emails; empty = every signed-in account
|
| 39 |
+
self._cache: dict[str, tuple[float, dict]] = {}
|
| 40 |
+
self._lock = threading.Lock()
|
| 41 |
+
|
| 42 |
+
def user(self, token: str) -> dict | None:
|
| 43 |
+
"""The signed-in user of this access token ({"id", "email"}), or None when it is not valid."""
|
| 44 |
+
now = time.monotonic()
|
| 45 |
+
with self._lock:
|
| 46 |
+
hit = self._cache.get(token)
|
| 47 |
+
if hit and hit[0] > now:
|
| 48 |
+
return hit[1]
|
| 49 |
+
req = urllib.request.Request(
|
| 50 |
+
self.url + "/auth/v1/user",
|
| 51 |
+
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "ihki/1.0"},
|
| 52 |
+
)
|
| 53 |
+
try:
|
| 54 |
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as res:
|
| 55 |
+
data = json.load(res)
|
| 56 |
+
except urllib.error.HTTPError as e:
|
| 57 |
+
if e.code in (400, 401, 403):
|
| 58 |
+
return None
|
| 59 |
+
raise AuthUnavailable(f"supabase answered {e.code}") from e
|
| 60 |
+
except (urllib.error.URLError, TimeoutError, ValueError) as e:
|
| 61 |
+
raise AuthUnavailable(str(e)) from e
|
| 62 |
+
if not data.get("id"):
|
| 63 |
+
return None
|
| 64 |
+
user = {"id": data["id"], "email": data.get("email", "")}
|
| 65 |
+
with self._lock:
|
| 66 |
+
if len(self._cache) >= CACHE_MAX:
|
| 67 |
+
self._cache.clear()
|
| 68 |
+
self._cache[token] = (now + CACHE_TTL, user)
|
| 69 |
+
return user
|
| 70 |
+
|
| 71 |
+
def record(self, token: str, row: dict) -> None:
|
| 72 |
+
"""Adds one row to `attempts` as the student of this token. A failure is logged, never raised."""
|
| 73 |
+
req = urllib.request.Request(
|
| 74 |
+
self.url + "/rest/v1/attempts", data=json.dumps(row).encode(), method="POST",
|
| 75 |
+
headers={"Authorization": f"Bearer {token}", "apikey": self.key, "Content-Type": "application/json",
|
| 76 |
+
"Prefer": "return=minimal", "User-Agent": "ihki/1.0"},
|
| 77 |
+
)
|
| 78 |
+
try:
|
| 79 |
+
urllib.request.urlopen(req, timeout=TIMEOUT).close()
|
| 80 |
+
except (urllib.error.URLError, TimeoutError) as e: # HTTPError is a URLError
|
| 81 |
+
log.warning("attempt not recorded: %s", e)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_auth() -> SupabaseAuth | None:
|
| 85 |
+
url = os.environ.get("SUPABASE_URL", "").strip()
|
| 86 |
+
key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
|
| 87 |
+
if not url or not key:
|
| 88 |
+
log.info("SUPABASE_URL / SUPABASE_PUBLISHABLE_KEY not set: no sign-in, the service is open")
|
| 89 |
+
return None
|
| 90 |
+
allowed = frozenset(re.split(r"[\s,;]+", os.environ.get("ALLOWED_EMAILS", "").strip().lower())) - {""}
|
| 91 |
+
log.info("sign-in on: %s", f"{len(allowed)} allowed accounts" if allowed else "any Google account")
|
| 92 |
+
return SupabaseAuth(url, key, allowed)
|
app/main.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
Run: uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 4 |
Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
|
| 5 |
SAWTAK_API_KEY, SAWTAK_VOICE_ID (model-answer playback, see app/tts.py)
|
|
|
|
| 6 |
A local .env file is loaded if present; real environment variables win over it.
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
|
@@ -17,10 +18,11 @@ from contextlib import asynccontextmanager
|
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
from dotenv import load_dotenv
|
| 20 |
-
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
| 21 |
from fastapi.responses import HTMLResponse, Response
|
| 22 |
from fastapi.staticfiles import StaticFiles
|
| 23 |
|
|
|
|
| 24 |
from .phrases import CATEGORIES, LESSONS, PHRASES, public_phrases
|
| 25 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 26 |
from .reference import References
|
|
@@ -44,22 +46,51 @@ def build_version() -> str:
|
|
| 44 |
return f"{h.hexdigest()[:7]} ยท {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
|
| 45 |
|
| 46 |
|
| 47 |
-
def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None
|
|
|
|
| 48 |
@asynccontextmanager
|
| 49 |
async def lifespan(app: FastAPI):
|
| 50 |
app.state.scorer = scorer or load_scorer(MODEL_ID)
|
| 51 |
app.state.tts = tts or load_tts()
|
| 52 |
# the playback voices double as the native baseline of the scores (see reference.py)
|
| 53 |
app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None
|
|
|
|
| 54 |
yield
|
| 55 |
|
| 56 |
app = FastAPI(title="ihki", lifespan=lifespan)
|
| 57 |
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
@app.get("/")
|
| 60 |
def index():
|
| 61 |
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
@app.get("/api/phrases")
|
| 65 |
def phrases():
|
|
@@ -73,7 +104,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None) -> Fa
|
|
| 73 |
"references": app.state.refs is not None,
|
| 74 |
"phoneme_fallbacks": s.vocab.downgraded}
|
| 75 |
|
| 76 |
-
@app.get("/api/tts/{phrase_id}")
|
| 77 |
def tts_phrase(phrase_id: int, voice: str = Query("", max_length=64)):
|
| 78 |
"""The model answer (first variant) spoken by the TTS voice. 503 tells the page to use the device voice.
|
| 79 |
|
|
@@ -96,7 +127,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None) -> Fa
|
|
| 96 |
"X-TTS-Voice-Id": urllib.parse.quote(used),
|
| 97 |
"X-TTS-Voice-Name": urllib.parse.quote(names.get(used) or getattr(tts, "voice_name", ""))})
|
| 98 |
|
| 99 |
-
@app.get("/api/prompt-audio/{phrase_id}")
|
| 100 |
def prompt_audio(phrase_id: int, engine: str = "gtts"):
|
| 101 |
"""The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
|
| 102 |
if not 0 <= phrase_id < len(PHRASES):
|
|
@@ -111,7 +142,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None) -> Fa
|
|
| 111 |
return Response(data, media_type="audio/mpeg", headers={
|
| 112 |
"Cache-Control": "private, max-age=86400", "X-TTS-Engine": used})
|
| 113 |
|
| 114 |
-
@app.get("/api/say")
|
| 115 |
def say(text: str = Query(..., min_length=1, max_length=400), engine: str = "gtts"):
|
| 116 |
"""A short Hebrew text read aloud: driving mode's spoken messages."""
|
| 117 |
if engine not in ENGINES:
|
|
@@ -125,7 +156,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None) -> Fa
|
|
| 125 |
return Response(data, media_type="audio/mpeg", headers={"Cache-Control": "no-store", "X-TTS-Engine": used})
|
| 126 |
|
| 127 |
@app.post("/api/score")
|
| 128 |
-
def score(phrase_id: int = Form(...), audio: UploadFile = File(...)
|
|
|
|
| 129 |
if not 0 <= phrase_id < len(PHRASES):
|
| 130 |
raise HTTPException(404, "unknown phrase")
|
| 131 |
data = audio.file.read(MAX_UPLOAD + 1)
|
|
@@ -139,7 +171,12 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None) -> Fa
|
|
| 139 |
return {"error": "too_short", "message": "ืื ื ืฉืืข ืืืืืจ. ื ืกื ืฉืื, ืงืจืื ืืืชืจ ืืืืงืจืืคืื."}
|
| 140 |
phrase = PHRASES[phrase_id]
|
| 141 |
refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else []
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
return app
|
| 145 |
|
|
|
|
| 3 |
Run: uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 4 |
Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a fine-tuned checkpoint)
|
| 5 |
SAWTAK_API_KEY, SAWTAK_VOICE_ID (model-answer playback, see app/tts.py)
|
| 6 |
+
SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, ALLOWED_EMAILS (Google sign-in, see app/auth.py)
|
| 7 |
A local .env file is loaded if present; real environment variables win over it.
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
|
|
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
from dotenv import load_dotenv
|
| 21 |
+
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
| 22 |
from fastapi.responses import HTMLResponse, Response
|
| 23 |
from fastapi.staticfiles import StaticFiles
|
| 24 |
|
| 25 |
+
from .auth import AuthUnavailable, SupabaseAuth, load_auth
|
| 26 |
from .phrases import CATEGORIES, LESSONS, PHRASES, public_phrases
|
| 27 |
from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
|
| 28 |
from .reference import References
|
|
|
|
| 46 |
return f"{h.hexdigest()[:7]} ยท {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
|
| 47 |
|
| 48 |
|
| 49 |
+
def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
| 50 |
+
auth: SupabaseAuth | None = None) -> FastAPI:
|
| 51 |
@asynccontextmanager
|
| 52 |
async def lifespan(app: FastAPI):
|
| 53 |
app.state.scorer = scorer or load_scorer(MODEL_ID)
|
| 54 |
app.state.tts = tts or load_tts()
|
| 55 |
# the playback voices double as the native baseline of the scores (see reference.py)
|
| 56 |
app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None
|
| 57 |
+
app.state.auth = auth or load_auth()
|
| 58 |
yield
|
| 59 |
|
| 60 |
app = FastAPI(title="ihki", lifespan=lifespan)
|
| 61 |
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 62 |
|
| 63 |
+
def signed_in(request: Request) -> dict | None:
|
| 64 |
+
"""The caller's user; 401 when sign-in is configured and the call carries no valid session token."""
|
| 65 |
+
a: SupabaseAuth | None = app.state.auth
|
| 66 |
+
if a is None:
|
| 67 |
+
return None
|
| 68 |
+
scheme, _, token = request.headers.get("authorization", "").partition(" ")
|
| 69 |
+
try:
|
| 70 |
+
user = a.user(token.strip()) if scheme.lower() == "bearer" and token.strip() else None
|
| 71 |
+
except AuthUnavailable as e:
|
| 72 |
+
raise HTTPException(503, f"sign-in check failed: {e}")
|
| 73 |
+
if user is None:
|
| 74 |
+
raise HTTPException(401, "sign in required")
|
| 75 |
+
if a.allowed and user["email"].lower() not in a.allowed:
|
| 76 |
+
raise HTTPException(403, "this account is not on the list")
|
| 77 |
+
return {**user, "token": token.strip()}
|
| 78 |
+
|
| 79 |
+
members = [Depends(signed_in)]
|
| 80 |
+
|
| 81 |
@app.get("/")
|
| 82 |
def index():
|
| 83 |
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
| 84 |
+
a: SupabaseAuth | None = app.state.auth
|
| 85 |
+
for mark, value in (("__VERSION__", build_version()), ("__SUPABASE_URL__", a.url if a else ""),
|
| 86 |
+
("__SUPABASE_KEY__", a.key if a else "")):
|
| 87 |
+
html = html.replace(mark, value)
|
| 88 |
+
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
|
| 89 |
+
|
| 90 |
+
@app.get("/api/me")
|
| 91 |
+
def me(user: dict | None = Depends(signed_in)):
|
| 92 |
+
"""Who is signed in; the page asks this right after sign-in to learn whether the account is let in."""
|
| 93 |
+
return {"email": user["email"] if user else ""}
|
| 94 |
|
| 95 |
@app.get("/api/phrases")
|
| 96 |
def phrases():
|
|
|
|
| 104 |
"references": app.state.refs is not None,
|
| 105 |
"phoneme_fallbacks": s.vocab.downgraded}
|
| 106 |
|
| 107 |
+
@app.get("/api/tts/{phrase_id}", dependencies=members)
|
| 108 |
def tts_phrase(phrase_id: int, voice: str = Query("", max_length=64)):
|
| 109 |
"""The model answer (first variant) spoken by the TTS voice. 503 tells the page to use the device voice.
|
| 110 |
|
|
|
|
| 127 |
"X-TTS-Voice-Id": urllib.parse.quote(used),
|
| 128 |
"X-TTS-Voice-Name": urllib.parse.quote(names.get(used) or getattr(tts, "voice_name", ""))})
|
| 129 |
|
| 130 |
+
@app.get("/api/prompt-audio/{phrase_id}", dependencies=members)
|
| 131 |
def prompt_audio(phrase_id: int, engine: str = "gtts"):
|
| 132 |
"""The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
|
| 133 |
if not 0 <= phrase_id < len(PHRASES):
|
|
|
|
| 142 |
return Response(data, media_type="audio/mpeg", headers={
|
| 143 |
"Cache-Control": "private, max-age=86400", "X-TTS-Engine": used})
|
| 144 |
|
| 145 |
+
@app.get("/api/say", dependencies=members)
|
| 146 |
def say(text: str = Query(..., min_length=1, max_length=400), engine: str = "gtts"):
|
| 147 |
"""A short Hebrew text read aloud: driving mode's spoken messages."""
|
| 148 |
if engine not in ENGINES:
|
|
|
|
| 156 |
return Response(data, media_type="audio/mpeg", headers={"Cache-Control": "no-store", "X-TTS-Engine": used})
|
| 157 |
|
| 158 |
@app.post("/api/score")
|
| 159 |
+
def score(tasks: BackgroundTasks, phrase_id: int = Form(...), audio: UploadFile = File(...),
|
| 160 |
+
user: dict | None = Depends(signed_in)):
|
| 161 |
if not 0 <= phrase_id < len(PHRASES):
|
| 162 |
raise HTTPException(404, "unknown phrase")
|
| 163 |
data = audio.file.read(MAX_UPLOAD + 1)
|
|
|
|
| 171 |
return {"error": "too_short", "message": "ืื ื ืฉืืข ืืืืืจ. ื ืกื ืฉืื, ืงืจืื ืืืชืจ ืืืืงืจืืคืื."}
|
| 172 |
phrase = PHRASES[phrase_id]
|
| 173 |
refs = app.state.refs.get(phrase_id, phrase) if app.state.refs else []
|
| 174 |
+
result = app.state.scorer.score(wav, phrase, refs)
|
| 175 |
+
if user and result.get("score") is not None: # the student's progress; sent after the answer
|
| 176 |
+
tasks.add_task(app.state.auth.record, user["token"], {
|
| 177 |
+
"cat": phrase["cat"], "phrase": phrase["he"], "score": round(result["score"]),
|
| 178 |
+
"unrelated": bool(result.get("unrelated"))})
|
| 179 |
+
return result
|
| 180 |
|
| 181 |
return app
|
| 182 |
|
static/index.html
CHANGED
|
@@ -84,6 +84,9 @@ header{display:flex;align-items:baseline;gap:4px 12px;flex-wrap:wrap}
|
|
| 84 |
.tips .what::after{content:" ยท "}
|
| 85 |
.tips .what{font-weight:700}
|
| 86 |
.tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
|
|
|
|
|
|
|
|
|
|
| 87 |
.version{position:fixed;bottom:4px;left:6px;z-index:9;font:500 11px/1 var(--f-ipa);direction:ltr;padding:3px 6px;border-radius:6px;background:var(--amber-bg);color:var(--amber);opacity:.85;pointer-events:none}
|
| 88 |
header .sub{flex:1}
|
| 89 |
.drive{font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
|
|
@@ -103,13 +106,13 @@ body.driving .mic{width:84px;height:84px}
|
|
| 103 |
#logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:28px;background:white;transition:opacity .35s ease,visibility .35s ease}
|
| 104 |
#logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
|
| 105 |
#logo-splash img{width:min(82vw,620px);height:auto}
|
| 106 |
-
#startBtn{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
|
| 107 |
@keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 108 |
-
@media (prefers-reduced-motion:reduce){#startBtn{animation:none;opacity:1}#logo-splash{transition:none}}
|
| 109 |
</style>
|
| 110 |
</head>
|
| 111 |
<body>
|
| 112 |
-
<div id="logo-splash"><img src="static/logo-intro.svg" alt="ืืืื ืืกืืื, ืืืช ืืกืคืจ ืืืืืื ืืฉืคื ืืขืจืืืช" width="489" height="225"><button id="startBtn">ืืืืื ื ืืืฉ</button></div>
|
| 113 |
<div class="wrap" id="wrap" inert>
|
| 114 |
<header>
|
| 115 |
<div class="brand">ืืืื ืืกืืื</div>
|
|
@@ -160,9 +163,21 @@ body.driving .mic{width:84px;height:84px}
|
|
| 160 |
<p class="note">ืื ืืชืื ื ืขืฉื ืืจืืช ืืืื. ืืืฉืืขื ื ืืฆืจืช ืืงืื ืฉื ืขืจืืืช ืืืืืจืช ื-Sawtak Arabi; ืื ืืฉืืจืืช ืืื ื ืืืื, ื ืขืฉื ืฉืืืืฉ ืืงืื ืืขืจืื ืฉื ืืืืฉืืจ.</p>
|
| 161 |
</div>
|
| 162 |
<div class="version" title="ืืจืกืช debug">v __VERSION__<span id="heInfo"></span><span id="ttsInfo"></span></div>
|
|
|
|
| 163 |
|
|
|
|
| 164 |
<script>
|
| 165 |
const $=id=>document.getElementById(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
|
| 167 |
let driving=false;
|
| 168 |
let LESSONS=[], lesson=null, CATS=[], PHRASES=[], cat=store.get("ihki.cat","meet"), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
|
|
@@ -219,7 +234,7 @@ let heAudio=null, hePending=null, opened=false; // opened: the sentence the ap
|
|
| 219 |
async function promptUrl(id){
|
| 220 |
if(!heUrls[id]){
|
| 221 |
try{
|
| 222 |
-
const res=await
|
| 223 |
if(!res.ok) return null;
|
| 224 |
heUrls[id]=URL.createObjectURL(await res.blob());
|
| 225 |
$("heInfo").textContent=" ยท ๐ฃ "+(res.headers.get("X-TTS-Engine")==="edge"?"edge-tts":"gTTS");
|
|
@@ -421,7 +436,7 @@ async function startRec(auto){ // auto: started by the page for the sentence o
|
|
| 421 |
mic.disabled=true; $("status").textContent="ืื ืชืโฆ";
|
| 422 |
try{
|
| 423 |
const fd=new FormData(); fd.append("phrase_id",id); fd.append("audio",blob,"rec");
|
| 424 |
-
const res=await
|
| 425 |
if(!res.ok) throw new Error(res.status);
|
| 426 |
const r=await res.json();
|
| 427 |
if(cur.id!==id){ mic.disabled=false; finishRec({kind:"cancel"}); recordWhenReady(); return; } // moved on while it was analysed
|
|
@@ -461,7 +476,7 @@ async function answerAudio(id){ // {url,label} of the Sawtak answer, or null w
|
|
| 461 |
ttsVoice="";
|
| 462 |
if(!ttsOff && !ttsUrls[key]){
|
| 463 |
try{
|
| 464 |
-
const res=await
|
| 465 |
if(res.ok){
|
| 466 |
const h=k=>decodeURIComponent(res.headers.get(k)||""), name=h("X-TTS-Voice-Name");
|
| 467 |
ttsUrls[key]={url:URL.createObjectURL(await res.blob()),
|
|
@@ -507,7 +522,7 @@ function speakLocal(text,lang){ // device voice, when the server could not spe
|
|
| 507 |
}
|
| 508 |
async function sayHe(text){
|
| 509 |
try{
|
| 510 |
-
const res=await
|
| 511 |
if(res.ok){ const url=URL.createObjectURL(await res.blob()); await playUrl(url); URL.revokeObjectURL(url); return; }
|
| 512 |
}catch{}
|
| 513 |
await speakLocal(text,"he-IL");
|
|
@@ -576,6 +591,32 @@ $("startBtn").onclick=()=>{
|
|
| 576 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
| 577 |
};
|
| 578 |
$("startBtn").focus({preventScroll:true});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
</script>
|
| 580 |
</body>
|
| 581 |
</html>
|
|
|
|
| 84 |
.tips .what::after{content:" ยท "}
|
| 85 |
.tips .what{font-weight:700}
|
| 86 |
.tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
|
| 87 |
+
#denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
|
| 88 |
+
#denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
|
| 89 |
+
#logoutBtn{position:fixed;bottom:4px;right:6px;z-index:9;font:500 11px/1 var(--f-body);padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
| 90 |
.version{position:fixed;bottom:4px;left:6px;z-index:9;font:500 11px/1 var(--f-ipa);direction:ltr;padding:3px 6px;border-radius:6px;background:var(--amber-bg);color:var(--amber);opacity:.85;pointer-events:none}
|
| 91 |
header .sub{flex:1}
|
| 92 |
.drive{font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
|
|
|
|
| 106 |
#logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:28px;background:white;transition:opacity .35s ease,visibility .35s ease}
|
| 107 |
#logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
|
| 108 |
#logo-splash img{width:min(82vw,620px);height:auto}
|
| 109 |
+
#startBtn,#loginBtn{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
|
| 110 |
@keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
| 111 |
+
@media (prefers-reduced-motion:reduce){#startBtn,#loginBtn{animation:none;opacity:1}#logo-splash{transition:none}}
|
| 112 |
</style>
|
| 113 |
</head>
|
| 114 |
<body>
|
| 115 |
+
<div id="logo-splash"><img src="static/logo-intro.svg" alt="ืืืื ืืกืืื, ืืืช ืืกืคืจ ืืืืืื ืืฉืคื ืืขืจืืืช" width="489" height="225"><button id="startBtn">ืืืืื ื ืืืฉ</button><button id="loginBtn" hidden>ืื ืืกื ืขื Google</button><div id="denied" hidden>ืืืฉืืื ืืื ืืื ื ืืจืฉืืืช ืืชืืืืืื.<button id="switchBtn">ืื ืืกื ืขื ืืฉืืื ืืืจ</button></div></div>
|
| 116 |
<div class="wrap" id="wrap" inert>
|
| 117 |
<header>
|
| 118 |
<div class="brand">ืืืื ืืกืืื</div>
|
|
|
|
| 163 |
<p class="note">ืื ืืชืื ื ืขืฉื ืืจืืช ืืืื. ืืืฉืืขื ื ืืฆืจืช ืืงืื ืฉื ืขืจืืืช ืืืืืจืช ื-Sawtak Arabi; ืื ืืฉืืจืืช ืืื ื ืืืื, ื ืขืฉื ืฉืืืืฉ ืืงืื ืืขืจืื ืฉื ืืืืฉืืจ.</p>
|
| 164 |
</div>
|
| 165 |
<div class="version" title="ืืจืกืช debug">v __VERSION__<span id="heInfo"></span><span id="ttsInfo"></span></div>
|
| 166 |
+
<button id="logoutBtn" hidden>ืืฆืืื</button>
|
| 167 |
|
| 168 |
+
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
| 169 |
<script>
|
| 170 |
const $=id=>document.getElementById(id);
|
| 171 |
+
/* Sign-in: Google through Supabase. The server fills the two values in; when they are empty (no project configured) the app is open. */
|
| 172 |
+
const AUTH_URL="__SUPABASE_URL__", AUTH_KEY="__SUPABASE_KEY__";
|
| 173 |
+
const sb=AUTH_URL&&window.supabase?supabase.createClient(AUTH_URL,AUTH_KEY):null;
|
| 174 |
+
async function api(url,opts={}){ // fetch with the session token; a refused token goes back to the sign-in screen
|
| 175 |
+
if(!sb) return fetch(url,opts);
|
| 176 |
+
const {data}=await sb.auth.getSession(); // refreshes the token when it has expired
|
| 177 |
+
const res=await fetch(url,{...opts,headers:{...opts.headers,Authorization:"Bearer "+(data.session?data.session.access_token:"")}});
|
| 178 |
+
if(res.status===401){ await sb.auth.signOut({scope:"local"}); location.reload(); }
|
| 179 |
+
return res;
|
| 180 |
+
}
|
| 181 |
const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
|
| 182 |
let driving=false;
|
| 183 |
let LESSONS=[], lesson=null, CATS=[], PHRASES=[], cat=store.get("ihki.cat","meet"), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
|
|
|
|
| 234 |
async function promptUrl(id){
|
| 235 |
if(!heUrls[id]){
|
| 236 |
try{
|
| 237 |
+
const res=await api(`/api/prompt-audio/${id}?engine=${heEngine}`);
|
| 238 |
if(!res.ok) return null;
|
| 239 |
heUrls[id]=URL.createObjectURL(await res.blob());
|
| 240 |
$("heInfo").textContent=" ยท ๐ฃ "+(res.headers.get("X-TTS-Engine")==="edge"?"edge-tts":"gTTS");
|
|
|
|
| 436 |
mic.disabled=true; $("status").textContent="ืื ืชืโฆ";
|
| 437 |
try{
|
| 438 |
const fd=new FormData(); fd.append("phrase_id",id); fd.append("audio",blob,"rec");
|
| 439 |
+
const res=await api("/api/score",{method:"POST",body:fd});
|
| 440 |
if(!res.ok) throw new Error(res.status);
|
| 441 |
const r=await res.json();
|
| 442 |
if(cur.id!==id){ mic.disabled=false; finishRec({kind:"cancel"}); recordWhenReady(); return; } // moved on while it was analysed
|
|
|
|
| 476 |
ttsVoice="";
|
| 477 |
if(!ttsOff && !ttsUrls[key]){
|
| 478 |
try{
|
| 479 |
+
const res=await api("/api/tts/"+id+(voice?"?voice="+encodeURIComponent(voice):""));
|
| 480 |
if(res.ok){
|
| 481 |
const h=k=>decodeURIComponent(res.headers.get(k)||""), name=h("X-TTS-Voice-Name");
|
| 482 |
ttsUrls[key]={url:URL.createObjectURL(await res.blob()),
|
|
|
|
| 522 |
}
|
| 523 |
async function sayHe(text){
|
| 524 |
try{
|
| 525 |
+
const res=await api(`/api/say?engine=${heEngine}&text=${encodeURIComponent(text)}`);
|
| 526 |
if(res.ok){ const url=URL.createObjectURL(await res.blob()); await playUrl(url); URL.revokeObjectURL(url); return; }
|
| 527 |
}catch{}
|
| 528 |
await speakLocal(text,"he-IL");
|
|
|
|
| 591 |
$("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
|
| 592 |
};
|
| 593 |
$("startBtn").focus({preventScroll:true});
|
| 594 |
+
/* With sign-in configured, the start button waits behind the Google button. */
|
| 595 |
+
if(AUTH_URL){
|
| 596 |
+
const start=$("startBtn"), login=$("loginBtn"), logout=$("logoutBtn");
|
| 597 |
+
start.hidden=true; login.hidden=false;
|
| 598 |
+
if(!sb){ login.disabled=true; login.textContent="ืืื ืืกื ืื ืืืื ื ืืจืืข"; }
|
| 599 |
+
else{
|
| 600 |
+
const denied=$("denied");
|
| 601 |
+
let asked=""; // the account whose permission was already asked; token refreshes do not ask again
|
| 602 |
+
login.onclick=()=>sb.auth.signInWithOAuth({provider:"google",
|
| 603 |
+
options:{redirectTo:location.origin+location.pathname,queryParams:{prompt:"select_account"}}});
|
| 604 |
+
logout.onclick=$("switchBtn").onclick=async()=>{ await sb.auth.signOut(); location.reload(); };
|
| 605 |
+
sb.auth.onAuthStateChange((_e,session)=>{
|
| 606 |
+
login.hidden=!!session; logout.hidden=!session;
|
| 607 |
+
logout.title=session?session.user.email||"":"";
|
| 608 |
+
if(!session){ asked=""; start.hidden=true; denied.hidden=true; login.focus({preventScroll:true}); return; }
|
| 609 |
+
if(asked===session.user.id) return;
|
| 610 |
+
asked=session.user.id;
|
| 611 |
+
setTimeout(async()=>{ // outside this callback: api() asks supabase for the session
|
| 612 |
+
const res=await api("/api/me").catch(()=>null);
|
| 613 |
+
const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
|
| 614 |
+
start.hidden=!ok; denied.hidden=ok;
|
| 615 |
+
if(ok) start.focus({preventScroll:true});
|
| 616 |
+
},0);
|
| 617 |
+
});
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
</script>
|
| 621 |
</body>
|
| 622 |
</html>
|
static/privacy.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="he" dir="rtl">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>ืืืื ืืืช ืคืจืืืืช ยท ืืืื ืืกืืื</title>
|
| 7 |
+
<style>
|
| 8 |
+
body{background:#EEF0E8;color:#1C241D;font:400 17px/1.6 "Assistant",system-ui,"Segoe UI",Arial,sans-serif;margin:0;padding:24px 16px}
|
| 9 |
+
main{max-width:680px;margin:0 auto;background:#FAFBF6;border:1px solid #D5DACB;border-radius:14px;padding:20px 24px}
|
| 10 |
+
h1{font-size:26px;margin:0 0 4px} h2{font-size:19px;margin:22px 0 4px}
|
| 11 |
+
.muted{color:#5E685C;font-size:14px} [lang=en]{direction:ltr;text-align:left}
|
| 12 |
+
</style>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<main>
|
| 16 |
+
<h1>ืืืื ืืืช ืคืจืืืืช</h1>
|
| 17 |
+
<p class="muted">ืืืื ืืกืืื (ihki) ยท ืขืืืื ื-9 ืืืืงืืืืจ 2026</p>
|
| 18 |
+
|
| 19 |
+
<h2>ืื ืืกื ืขื Google</h2>
|
| 20 |
+
<p>ืืื ืืกื ืืืคืืืงืฆืื ื ืขืฉืืช ืืืืฆืขืืช ืืฉืืื Google. ืืขืช ืืื ืืกื ืื ืื ื ืืงืืืื ื-Google ืืช ืืฉื, ืืชืืืช ืืืื"ื ืืชืืื ืช ืืคืจืืคืื ืฉืืื, ืืฉืืืจืื ืืืชื ืืฉืืจืืช Supabase ืฉืื ืื ืขืืืจื ื ืืช ืืืฉืืื ืืช. ืืกืืกืื ืฉืืื ื-Google ืืื ื ืืืืขื ืืืื ื.</p>
|
| 21 |
+
|
| 22 |
+
<h2>ืืงืืืืช</h2>
|
| 23 |
+
<p>ืืฉืืชื ืืงืืืืื ืืฉืคื, ืืืงืืื ื ืฉืืืช ืืฉืจืช ืืื ืื ืชื ืืช ืืืืืื ืืืืืืืจ ืฆืืื. ืืืงืืื ืืื ื ื ืฉืืจืช ืืฉืจืช ืืืืจ ืื ืืชืื.</p>
|
| 24 |
+
|
| 25 |
+
<h2>ืืชืงืืืืช ืืืืืื</h2>
|
| 26 |
+
<p>ืืืืจ ืื ืืงืืื ื ืฉืืจืื ื-Supabase, ืชืืช ืืืฉืืื ืฉืืื, ืืืฉืคื ืฉืชืจืืืชื, ืืฆืืื ืืืืืขื. ืืืืจื ืืืื ืืจืืืช ืืช ืื ืชืื ืื ืืืื ืืื ืืขืงืื ืืืจ ืืืชืงืืืืช ืฉืืื.</p>
|
| 27 |
+
|
| 28 |
+
<h2>ืื ื ืฉืืจ ืืืืฉืืจ ืฉืืื</h2>
|
| 29 |
+
<p>ืืฆืืื ืื ืืืืืื ืืืืชืจ ืืืืขืืคืืช ืฉืืื (ืฉืืขืืจ, ืงืื) ื ืฉืืจืื ืืืคืืคื ืฉืืื ืืืื, ืืื ืขื ืคืจืื ืืืชืืืจืืช.</p>
|
| 30 |
+
|
| 31 |
+
<h2>ืฉืืชืืฃ ืขื ืฆืืืื ืฉืืืฉืืื</h2>
|
| 32 |
+
<p>ืืื ื ื ืืืืจืื ืื ืืฉืชืคืื ืืช ืืคืจืืื ืฉืืื. ืืฉืืจืืชืื ืฉืืื ืืืคืืืงืฆืื ืืฉืชืืฉืช: Google (ืื ืืกื), Supabase (ื ืืืื ืืฉืืื ืืช) ื-Hugging Face (ืืืจืื).</p>
|
| 33 |
+
|
| 34 |
+
<h2>ืืืืงื ืืืฆืืจืช ืงืฉืจ</h2>
|
| 35 |
+
<p>ืืืืืงืช ืืืฉืืื ืืืคืจืืื ืฉืืื, ืื ืืื ืฉืืื, ืืชืื ืื <a href="mailto:wzwzchatgpt@gmail.com">wzwzchatgpt@gmail.com</a>.</p>
|
| 36 |
+
|
| 37 |
+
<h2 lang="en">Summary in English</h2>
|
| 38 |
+
<p lang="en">Sign-in uses your Google account: we receive your name, email address and profile picture, stored with Supabase, which manages accounts for us. Recordings are sent to the server only to score pronunciation and are not kept afterwards. For each recording we store the phrase practised, the score and the time under your account, and your teacher can see them to follow your progress. Best scores and preferences stay in your browser. We do not sell or share your data. To delete your account or ask a question, write to <a href="mailto:wzwzchatgpt@gmail.com">wzwzchatgpt@gmail.com</a>.</p>
|
| 39 |
+
</main>
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests run without sign-in whatever the local .env says: set before app.main loads it."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
os.environ["SUPABASE_URL"] = ""
|
tests/test_scoring.py
CHANGED
|
@@ -405,3 +405,52 @@ def test_workbook_lesson_2():
|
|
| 405 |
assert by["l2-say", "ืื ื ืืฉืืชื ืขื ืืืจื ืืคื ื ืืืืืื ืืืืื ื ืคืืฆื ืืกืืื."]["he_say"].split()[3] == "ืึฒืึตืจึดื"
|
| 406 |
assert "ืึทืึฒืึตืจึดื" in by["l2-say", "ืื ืืฉื ืืืืช ืืฉืื'? ืื ื ืืืืจื ืืฉืื ื ืืืืช ืืฉืื'."]["he_say"]
|
| 407 |
assert by["l2-dual", "ืฉืืืข, ืฉืืืขืืื"]["he_say"] == "ืฉึธืืืึผืขึท, ืฉืืืขืืื" # not "shvua"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
assert by["l2-say", "ืื ื ืืฉืืชื ืขื ืืืจื ืืคื ื ืืืืืื ืืืืื ื ืคืืฆื ืืกืืื."]["he_say"].split()[3] == "ืึฒืึตืจึดื"
|
| 406 |
assert "ืึทืึฒืึตืจึดื" in by["l2-say", "ืื ืืฉื ืืืืช ืืฉืื'? ืื ื ืืืืจื ืืฉืื ื ืืืืช ืืฉืื'."]["he_say"]
|
| 407 |
assert by["l2-dual", "ืฉืืืข, ืฉืืืขืืื"]["he_say"] == "ืฉึธืืืึผืขึท, ืฉืืืขืืื" # not "shvua"
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def test_sign_in_guards_the_practice_endpoints():
|
| 411 |
+
from fastapi.testclient import TestClient
|
| 412 |
+
|
| 413 |
+
from app.main import create_app
|
| 414 |
+
|
| 415 |
+
class FakeAuth:
|
| 416 |
+
url, key, allowed = "https://proj.supabase.co", "sb_publishable_x", frozenset()
|
| 417 |
+
|
| 418 |
+
def __init__(self):
|
| 419 |
+
self.rows = []
|
| 420 |
+
|
| 421 |
+
def user(self, token):
|
| 422 |
+
return {"id": "u1", "email": "A@b.c"} if token == "good" else None
|
| 423 |
+
|
| 424 |
+
def record(self, token, row):
|
| 425 |
+
self.rows.append((token, row))
|
| 426 |
+
|
| 427 |
+
with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
|
| 428 |
+
page = c.get("/").text
|
| 429 |
+
assert 'AUTH_URL="https://proj.supabase.co"' in page and 'AUTH_KEY="sb_publishable_x"' in page
|
| 430 |
+
assert c.get("/api/phrases").status_code == 200 # the phrase list stays public
|
| 431 |
+
assert c.get("/api/prompt-audio/0").status_code == 401
|
| 432 |
+
assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
|
| 433 |
+
assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
|
| 434 |
+
good = {"Authorization": "Bearer good"}
|
| 435 |
+
assert c.get("/api/me", headers=good).json() == {"email": "A@b.c"}
|
| 436 |
+
c.app.state.auth.allowed = frozenset({"other@b.c"}) # a list without this account
|
| 437 |
+
assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
|
| 438 |
+
c.app.state.auth.allowed = frozenset({"a@b.c"}) # case does not matter
|
| 439 |
+
assert c.get("/api/me", headers=good).status_code == 200
|
| 440 |
+
# a scored recording is recorded as the student's attempt
|
| 441 |
+
auth, pid = FakeAuth(), PHRASES.index(TOILET)
|
| 442 |
+
buf = io.BytesIO()
|
| 443 |
+
with wave.open(buf, "wb") as w:
|
| 444 |
+
w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
|
| 445 |
+
w.writeframes((0.3 * np.sin(2 * np.pi * 220 * np.arange(16000) / 16000) * 32767).astype("<i2").tobytes())
|
| 446 |
+
logp = emissions(["w", "eห", "n", "i", "l", "ฯ", "a", "m", "aห", "m"])
|
| 447 |
+
with TestClient(create_app(scorer_for(logp), auth=auth)) as c:
|
| 448 |
+
files = {"audio": ("a.wav", buf.getvalue(), "audio/wav")}
|
| 449 |
+
assert c.post("/api/score", data={"phrase_id": pid}, files=files).status_code == 401 and not auth.rows
|
| 450 |
+
r = c.post("/api/score", data={"phrase_id": pid}, files=files, headers={"Authorization": "Bearer good"})
|
| 451 |
+
assert r.status_code == 200, r.text
|
| 452 |
+
(token, row), = auth.rows
|
| 453 |
+
assert token == "good" and row["phrase"] == TOILET["he"] and row["cat"] == TOILET["cat"]
|
| 454 |
+
assert row["score"] == round(r.json()["score"]) and row["unrelated"] is False
|
| 455 |
+
with TestClient(create_app(scorer_for(emissions(["w"])))) as c: # not configured: open, as before
|
| 456 |
+
assert 'AUTH_URL=""' in c.get("/").text and c.get("/api/tts/0").status_code == 503
|