3v324v23 commited on
Commit
08aea0e
ยท
1 Parent(s): 969fda1

Implement Google sign-in with Supabase and add related functionality

Browse files
.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) -> FastAPI:
 
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
- return HTMLResponse(html.replace("__VERSION__", build_version()), headers={"Cache-Control": "no-cache"})
 
 
 
 
 
 
 
 
 
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
- return app.state.scorer.score(wav, phrase, refs)
 
 
 
 
 
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 fetch(`/api/prompt-audio/${id}?engine=${heEngine}`);
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 fetch("/api/score",{method:"POST",body:fd});
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 fetch("/api/tts/"+id+(voice?"?voice="+encodeURIComponent(voice):""));
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 fetch(`/api/say?engine=${heEngine}&text=${encodeURIComponent(text)}`);
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