3v324v23 commited on
Commit
35394c4
ยท
1 Parent(s): c15166b

Add keystore for managing API keys securely and update tests

Browse files

- Implemented a new KeyStore class to handle API keys securely, encrypting them and ensuring they are never exposed.
- Added functionality to load, save, and clear keys, with validation against their respective providers.
- Created tests for the KeyStore to ensure keys are stored encrypted, swapped in place, and reverted to environment variables when cleared.
- Updated existing tests to include checks for new functionality related to API keys and tutor model management.

.env.example CHANGED
@@ -55,3 +55,9 @@ APP_VERSION=
55
  BUILD_DATE=
56
  GIT_COMMIT=
57
  GIT_COMMIT_MESSAGE=
 
 
 
 
 
 
 
55
  BUILD_DATE=
56
  GIT_COMMIT=
57
  GIT_COMMIT_MESSAGE=
58
+
59
+ # Lets a developer replace SAWTAK_API_KEY / ANTHROPIC_API_KEY from the management panel (kept encrypted in Supabase,
60
+ # table app_secrets; see the README). Make one with:
61
+ # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
62
+ # Keep it only here / as a Space secret. Losing it loses the keys set in the panel (the ones above still work).
63
+ SECRETS_MASTER_KEY=
README.md CHANGED
@@ -158,6 +158,28 @@ create index system_events_created_at_idx on public.system_events (created_at de
158
  alter table public.system_events enable row level security;
159
  ```
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  For an existing `attempts` table, add the compact diagnostics once. Until this migration is run, attempts continue to be saved without diagnostics:
162
 
163
  ```sql
@@ -610,3 +632,45 @@ Tune them on a handful of labelled learner recordings. A useful target is that n
610
  - Playback, candidate generation and score calibration all use the Palestinian voice ืจึทืืึดืฃ.
611
  - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
612
  - The same ืจึทืืึดืฃ voice calibrates the scores (step 5 above), once for every phrase that gets scored, also when the learner never presses "ื”ืฉืžืข". The first score of a phrase waits up to 8 s for it. Without a key the scores are not calibrated; `/api/health` reports this as `references`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  alter table public.system_events enable row level security;
159
  ```
160
 
161
+ API keys a developer replaces from the panel (`SAWTAK_API_KEY`, `ANTHROPIC_API_KEY`, see `app/keystore.py`), and the tutor's model (`TUTOR_MODEL`, chosen from a list or typed), so changing one needs no access to the Space. Each value is stored encrypted with `SECRETS_MASTER_KEY`, a Space secret, and the page never gets a key back (the model, which is no secret, it shows). No browser role can read the table:
162
+
163
+ ```sql
164
+ create table public.app_secrets (
165
+ name text primary key check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL')),
166
+ ciphertext text not null,
167
+ last4 text not null default '',
168
+ updated_by text not null default '',
169
+ updated_at timestamptz not null default now()
170
+ );
171
+ alter table public.app_secrets enable row level security;
172
+ revoke all on public.app_secrets from anon, authenticated;
173
+ ```
174
+
175
+ A table made before the panel could set `TUTOR_MODEL` needs its name check widened once (saving the model answers 409 with this SQL until then):
176
+
177
+ ```sql
178
+ alter table public.app_secrets drop constraint if exists app_secrets_name_check;
179
+ alter table public.app_secrets add constraint app_secrets_name_check
180
+ check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL'));
181
+ ```
182
+
183
  For an existing `attempts` table, add the compact diagnostics once. Until this migration is run, attempts continue to be saved without diagnostics:
184
 
185
  ```sql
 
632
  - Playback, candidate generation and score calibration all use the Palestinian voice ืจึทืืึดืฃ.
633
  - Without a key the endpoint answers 503 and the page falls back to the device's Arabic voice.
634
  - The same ืจึทืืึดืฃ voice calibrates the scores (step 5 above), once for every phrase that gets scored, also when the learner never presses "ื”ืฉืžืข". The first score of a phrase waits up to 8 s for it. Without a key the scores are not calibrated; `/api/health` reports this as `references`.
635
+
636
+ ## Cost estimate (API keys)
637
+
638
+ Estimated 2026-10-18 for **100 students, each doing 5 tutor chats a day (the daily limit, `DAILY_LIMIT`) of 10 turns each**: 500 chats a day, 15,000 a month (30 days). Anthropic prices are list prices per million tokens; the Sawtak price is the rate your own account was charged.
639
+
640
+ ### Anthropic (`ANTHROPIC_API_KEY`) โ€” the tutor game ื™ืืœืœื” ื ื“ื‘ืจ
641
+
642
+ How a chat is billed: 11 calls (the opening plus 10 turns). Each call sends the scenario's system prompt and the reply schema (the prefix), plus the conversation so far. The prefix grows with the lessons' vocabulary: 4,364 tokens in lesson 1, 16,466 mid-course and 22,728 in the last lesson (measured with `count_tokens`; the 5.5 models count the same). Each turn adds about 540 tokens of history, and each answer is about 490 output tokens. `cache_control` caches the prompt, so every call after the first reads the earlier part from the cache and writes only the new turn. That assumes the turns are less than 5 minutes apart, which a spoken conversation is.
643
+
644
+ Tokens per chat (mid-course): ~22K cache writes (1.25ร— the input price), ~189K cache reads, ~5.7K output.
645
+
646
+ | Model (`TUTOR_MODEL`) | $ in / out / cache read per 1M | Per chat (mid-course) | Per day | **Per month**: lesson 1 / mid / last | Per student per month (mid) |
647
+ |---|---|---|---|---|---|
648
+ | `claude-haiku-5-5` (the default) | 0.10 / 0.50 / 0.01 | $0.0075 | $3.73 | **$71 / $112 / $133** | $1.12 |
649
+ | `claude-haiku-4-5` | 1 / 5 / 0.10 | $0.075 | $37 | $712 / $1,120 / $1,332 | $11.20 |
650
+ | `claude-sonnet-5-5` | 2 / 10 / 0.20 | $0.149 | $75 | $1,424 / $2,241 / $2,663 | $22.40 |
651
+ | `claude-opus-5-5` ยน | 4 / 20 / 0.20 | $0.261 | $130 | $2,644 / $3,914 / $4,572 | $39.10 |
652
+ | `claude-fable-5-1` ยน | 10 / 50 / 0.25 | $0.605 | $303 | $6,354 / $9,077 / $10,486 | $90.80 |
653
+
654
+ - **Without the prompt cache** the input would cost 3โ€“3.5ร— more (for example Haiku 5.5 mid-course: $359 a month instead of $112). Keep `cache_control` in `Tutor._ask`.
655
+ - These are upper bounds for the volume: every student uses all 5 chats, every day, all 10 turns. Real use is usually lower; `tutor_chats` records each chat's `input_tokens` and `output_tokens`, so the real bill can be checked against this table.
656
+ - ยน Opus 5.5 and Fable 5.1 cannot turn thinking off, so the tutor leaves it adaptive at `effort: "low"` (`thinking_for()` in `app/tutor.py`). The thinking tokens they spend are billed as output and are not in this table, so expect somewhat more for these two.
657
+ - Set a monthly spending limit in the Console (Settings โ†’ Limits), so that a bug or unexpected use cannot cost more than planned.
658
+
659
+ ### Sawtak Arabi (`SAWTAK_API_KEY`) โ€” the model-answer voice
660
+
661
+ Sawtak charges per character of text spoken. This account was charged **$5.00 for 99,600 characters (about $0.05 per 1,000 characters, $50 per million)** in the 30 days before 2026-10-18 (`GET /v1/usage`).
662
+
663
+ - **If the tutor's messages are spoken with Sawtak too** (the code does not do this yet), each one costs again: every message is new text, so none comes from the cache. A message is one or two short sentences, about 90 characters with vowel marks (the phrase bank averages 51 per phrase). A chat has 11 messages (the opening and 10 answers), so about 1,000 characters:
664
+
665
+ | | Characters | Cost |
666
+ |---|---|---|
667
+ | Per chat | ~990 | ~$0.05 |
668
+ | Per day (500 chats) | ~495,000 | ~$25 |
669
+ | **Per month (15,000 chats)** | ~14.9 million | **~$745** (between ~$500 and ~$1,000 for messages of 60โ€“120 characters) |
670
+ | Per student per month | ~149,000 | ~$7.45 |
671
+
672
+ That is more than the tutor's model costs on Haiku 5.5 (~$112 a month), so voicing the tutor would be most of the bill.
673
+ - The rest of the cost comes from "๐Ÿ”Š ื”ืฉืžืข" and from the native baseline the scores are calibrated against, both for the phrase bank:
674
+ - Each phrase is generated once and kept in the voice cache (`TTS_CACHE_DIR`). All 3,546 phrases come to about 180,000 characters with their vowel marks, so **voicing the whole bank costs about $9, once**. A text's dialogue lines in the second voice add a little.
675
+ - **The number of students does not change this cost**: 100 students cost the same as 1, because the second student to play a phrase gets it from the cache.
676
+ - What does cost more: the cache is a folder on the server's disk. A Hugging Face Space without persistent storage loses it on every restart and rebuild, and "ืžื—ื™ืงืช ืžื˜ืžื•ืŸ ื”ื”ืงืœื˜ื•ืช" in the panel empties it. After either, the phrases are generated again as they are played, up to about $9 each time. To avoid that, give the Space persistent storage and set `TTS_CACHE_DIR=/data/ihki-tts`.
app/align.py CHANGED
@@ -111,6 +111,7 @@ class TokenScore:
111
  realised: int | None = None # a class target (several correct realisations): the one said
112
  other: bool = False # the strongest competitor is a sound outside the named set
113
  comp_peak: float = NEG # max log-posterior of the strongest competitor (named or not) in the window
 
114
  window: tuple[int, int] = (0, 0) # [start, end) frames the target and its competitors were read from
115
 
116
 
@@ -239,6 +240,9 @@ def compute_token_gop(logp: np.ndarray, spans: list[tuple[int, int]], i: int, to
239
  ratio[mask] = NEG
240
  comp = int(ratio.argmax())
241
  comp_peak = float(maxed[comp]) if ratio[comp] > NEG / 2 else NEG
 
 
 
242
  tseg = logp[ra:rb, [tok]]
243
  target = float(tseg.max()) if how == "max" else float(pool_frames(tseg, how, cfg.pool_k, cfg.pool_tau, weight)[0])
244
 
@@ -248,7 +252,7 @@ def compute_token_gop(logp: np.ndarray, spans: list[tuple[int, int]], i: int, to
248
  present = comp_peak > NEG / 2
249
  other = bool(present and named is not None and comp not in named)
250
  return TokenScore(round(score, 1), round(gop, 3), round(peak, 3), comp if present and not other else None,
251
- deleted, realised, other, round(comp_peak, 3), (int(ra), int(rb)))
252
 
253
 
254
  def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
 
111
  realised: int | None = None # a class target (several correct realisations): the one said
112
  other: bool = False # the strongest competitor is a sound outside the named set
113
  comp_peak: float = NEG # max log-posterior of the strongest competitor (named or not) in the window
114
+ comp_frames: int = 0 # frames where that competitor clears deleted_comp (duration evidence)
115
  window: tuple[int, int] = (0, 0) # [start, end) frames the target and its competitors were read from
116
 
117
 
 
240
  ratio[mask] = NEG
241
  comp = int(ratio.argmax())
242
  comp_peak = float(maxed[comp]) if ratio[comp] > NEG / 2 else NEG
243
+ # A lone CTC spike can be a breath or microphone onset rather than a produced consonant. Keep duration
244
+ # evidence separately from the peak so pronunciation policy can distinguish transients from substitutions.
245
+ comp_frames = int(np.count_nonzero(rcols[:, comp] >= cfg.deleted_comp)) if ratio[comp] > NEG / 2 else 0
246
  tseg = logp[ra:rb, [tok]]
247
  target = float(tseg.max()) if how == "max" else float(pool_frames(tseg, how, cfg.pool_k, cfg.pool_tau, weight)[0])
248
 
 
252
  present = comp_peak > NEG / 2
253
  other = bool(present and named is not None and comp not in named)
254
  return TokenScore(round(score, 1), round(gop, 3), round(peak, 3), comp if present and not other else None,
255
+ deleted, realised, other, round(comp_peak, 3), comp_frames, (int(ra), int(rb)))
256
 
257
 
258
  def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
app/auth.py CHANGED
@@ -661,6 +661,24 @@ class SupabaseAuth:
661
  log.warning("access changed but audit row was not stored: %s", e)
662
  self.log_event("permission", actor, target["email"], {"roles": roles, "entitlements": entitlements})
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  def all_attempts(self) -> list[dict]:
665
  """Every student's attempts, as `attempts` gives one student's, each with its user_id. "Different sentence"
666
  rows remain marked as unrelated for diagnostics, but count toward progress like every non-empty recording."""
 
661
  log.warning("access changed but audit row was not stored: %s", e)
662
  self.log_event("permission", actor, target["email"], {"roles": roles, "entitlements": entitlements})
663
 
664
+ def secret_rows(self) -> list[dict] | None:
665
+ """The API keys set in the panel, encrypted (see keystore.py), or None until the table `app_secrets` exists."""
666
+ try:
667
+ return self._admin("/rest/v1/app_secrets?select=name,ciphertext,last4,updated_by,updated_at") or []
668
+ except AuthUnavailable as e:
669
+ if "answered 404" in str(e):
670
+ return None
671
+ raise
672
+
673
+ def save_secret(self, row: dict) -> None:
674
+ self._admin("/rest/v1/app_secrets?on_conflict=name", "POST",
675
+ {**row, "updated_at": datetime.now(timezone.utc).isoformat()},
676
+ prefer="resolution=merge-duplicates,return=minimal")
677
+
678
+ def delete_secret(self, name: str) -> None:
679
+ self._admin("/rest/v1/app_secrets?" + urllib.parse.urlencode({"name": f"eq.{name}"}), "DELETE",
680
+ prefer="return=minimal")
681
+
682
  def all_attempts(self) -> list[dict]:
683
  """Every student's attempts, as `attempts` gives one student's, each with its user_id. "Different sentence"
684
  rows remain marked as unrelated for diagnostics, but count toward progress like every non-empty recording."""
app/keystore.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API keys a developer sets in the management panel (SAWTAK_API_KEY, ANTHROPIC_API_KEY), and the tutor's model
2
+ (TUTOR_MODEL), so changing one needs no access to the host (the Hugging Face Space).
3
+
4
+ Each key is kept in the Supabase table `app_secrets`, encrypted with Fernet under SECRETS_MASTER_KEY, which lives only in
5
+ the host's environment: the table, a backup of it or a look at the Supabase dashboard shows no key. The table has
6
+ row-level security on and no policy, so only the server's secret key reads it. A key never goes back to the page: the
7
+ panel sees whether it is set, where from, and its last four characters. A new key is tried with its provider before it
8
+ is kept, so a typo cannot switch the playback or the tutor off. TUTOR_MODEL is no secret: it is kept the same way, but
9
+ the panel shows it, and a model is checked by asking Anthropic for it with the key in use.
10
+
11
+ A key kept in the table takes the place of the environment's; without one (or without the table, the secret key or the
12
+ master key) the environment's is used, as before.
13
+
14
+ Env: SECRETS_MASTER_KEY (a Fernet key; make one with
15
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
16
+ and keep it as a Space secret. Losing it loses the stored keys, not the environment's.)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ import threading
24
+ import urllib.error
25
+ import urllib.request
26
+
27
+ from .auth import AuthUnavailable
28
+
29
+ log = logging.getLogger("ihki.keys")
30
+ # the keys the panel may set, and how the panel names them; nothing else (the master key, the Supabase keys) is settable
31
+ NAMES = {"SAWTAK_API_KEY": "Sawtak Arabi ยท ื”ืฉืžืขืช ื”ืชืฉื•ื‘ื”", "ANTHROPIC_API_KEY": "Anthropic ยท ื”ืžื•ืจื” (ื™ืืœืœื” ื ื“ื‘ืจ)",
32
+ "TUTOR_MODEL": "ื”ืžื•ื“ืœ ืฉืœ ื”ืžื•ืจื” (TUTOR_MODEL)"}
33
+ FALLBACK = {"ANTHROPIC_API_KEY": ("ANTHROPIC_API_KEY", "CLAUDE_API_KEY"), "SAWTAK_API_KEY": ("SAWTAK_API_KEY",),
34
+ "TUTOR_MODEL": ("TUTOR_MODEL",)}
35
+ PUBLIC = frozenset({"TUTOR_MODEL"}) # settings the panel may show in full
36
+ # the panel's list of tutor models (any other id can be typed); empty = the code's default (tutor.DEFAULT_MODEL)
37
+ MODEL_CHOICES = [("claude-haiku-5-5", "Haiku 5.5 ยท ื‘ืจื™ืจืช ื”ืžื—ื“ืœ, ื”ื–ื•ืœ ื‘ื™ื•ืชืจ"), ("claude-sonnet-5-5", "Sonnet 5.5"),
38
+ ("claude-opus-5-5", "Opus 5.5"), ("claude-fable-5-1", "Fable 5.1 ยท ื”ื™ืงืจ ื‘ื™ื•ืชืจ"), ("claude-haiku-4-5", "Haiku 4.5")]
39
+ MODEL_ID = re.compile(r"^claude-[a-z0-9][a-z0-9.-]{2,60}$")
40
+ MAX_LEN = 300
41
+ TIMEOUT = 15
42
+ CREATE_APP_SECRETS = ("create table public.app_secrets (name text primary key check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL')), "
43
+ "ciphertext text not null, last4 text not null default '', updated_by text not null default '', "
44
+ "updated_at timestamptz not null default now()); "
45
+ "alter table public.app_secrets enable row level security; "
46
+ "revoke all on public.app_secrets from anon, authenticated;")
47
+ # for a table made before TUTOR_MODEL could be kept in it
48
+ MIGRATE_APP_SECRETS = ("alter table public.app_secrets drop constraint if exists app_secrets_name_check; "
49
+ "alter table public.app_secrets add constraint app_secrets_name_check "
50
+ "check (name in ('SAWTAK_API_KEY','ANTHROPIC_API_KEY','TUTOR_MODEL'));")
51
+
52
+
53
+ class KeyRefused(ValueError):
54
+ """The provider refused the key, or it could not be tried. The message is safe to show."""
55
+
56
+
57
+ class KeyStore:
58
+ def __init__(self, auth, master: str = ""):
59
+ self.auth = auth
60
+ self._fernet, self.why = None, "" # why: what keeps the panel from saving keys, in words for the page
61
+ if master:
62
+ try:
63
+ from cryptography.fernet import Fernet
64
+ self._fernet = Fernet(master.strip().encode())
65
+ except ImportError:
66
+ self.why = "ื”ื—ื‘ื™ืœื” cryptography ืœื ืžื•ืชืงื ืช ื‘ืฉืจืช."
67
+ except ValueError:
68
+ log.error("SECRETS_MASTER_KEY is not a valid Fernet key: keys set in the panel are off")
69
+ self.why = "SECRETS_MASTER_KEY ื‘ืฉืจืช ืื™ื ื• ืžืคืชื— Fernet ืชืงื™ืŸ."
70
+ else:
71
+ self.why = "ื—ืกืจ SECRETS_MASTER_KEY ื‘ืกื•ื“ื•ืช ื”ืฉืจืช, ื•ืœื›ืŸ ืื™ ืืคืฉืจ ืœืฉืžื•ืจ ืžืคืชื—ื•ืช ืžื”ืคืื ืœ."
72
+ if not self.why and not getattr(auth, "secret", ""):
73
+ self.why = "ื—ืกืจ SUPABASE_SECRET_KEY ื‘ืฉืจืช, ื•ืœื›ืŸ ืื™ ืืคืฉืจ ืœืฉืžื•ืจ ืžืคืชื—ื•ืช ืžื”ืคืื ืœ."
74
+ self.missing = False # the table is not there yet (CREATE_APP_SECRETS)
75
+ self._rows: dict[str, dict] = {} # name -> {"value", "last4", "updated_by", "updated_at"}
76
+ self._lock = threading.Lock()
77
+
78
+ @property
79
+ def writable(self) -> bool:
80
+ return not self.why and not self.missing
81
+
82
+ def _seal(self, name: str, value: str) -> str:
83
+ # the name goes inside the ciphertext, so one key's row copied into another's does not decrypt as it
84
+ return self._fernet.encrypt(f"{name}\n{value}".encode()).decode()
85
+
86
+ def _open(self, name: str, token: str) -> str | None:
87
+ from cryptography.fernet import InvalidToken
88
+ try:
89
+ got, _, value = self._fernet.decrypt(token.encode()).decode().partition("\n")
90
+ except (InvalidToken, ValueError):
91
+ return None
92
+ return value if got == name else None
93
+
94
+ def load(self) -> None:
95
+ """Reads the stored keys. A failure is logged and leaves the environment's keys in use."""
96
+ if self.why or not hasattr(self.auth, "secret_rows"):
97
+ return
98
+ try:
99
+ rows = self.auth.secret_rows()
100
+ except AuthUnavailable as e:
101
+ log.warning("stored API keys not read, the environment's are used: %s", e)
102
+ return
103
+ if rows is None:
104
+ self.missing = True
105
+ return
106
+ found = {}
107
+ for r in rows:
108
+ value = self._open(r["name"], r.get("ciphertext") or "") if r.get("name") in NAMES else None
109
+ if value is None:
110
+ log.warning("stored key %s does not decrypt with this SECRETS_MASTER_KEY: the environment's is used", r.get("name"))
111
+ continue
112
+ found[r["name"]] = {"value": value, "last4": r.get("last4") or "", "updated_by": r.get("updated_by") or "",
113
+ "updated_at": r.get("updated_at")}
114
+ with self._lock:
115
+ self._rows = found
116
+
117
+ @staticmethod
118
+ def _env(name: str) -> str:
119
+ return next((v for v in (os.environ.get(n, "").strip() for n in FALLBACK[name]) if v), "")
120
+
121
+ def get(self, name: str) -> str:
122
+ """The key in use: the panel's, else the environment's, else empty."""
123
+ with self._lock:
124
+ row = self._rows.get(name)
125
+ return row["value"] if row else self._env(name)
126
+
127
+ def status(self) -> list[dict]:
128
+ """What the panel shows of each key: never the key itself."""
129
+ out = []
130
+ for name, label in NAMES.items():
131
+ with self._lock:
132
+ row = self._rows.get(name)
133
+ env = self._env(name)
134
+ item = {"name": name, "label": label, "source": "db" if row else "env" if env else "none",
135
+ "last4": "" if name in PUBLIC else row["last4"] if row else env[-4:] if len(env) >= 12 else "",
136
+ "updated_by": row["updated_by"] if row else "", "updated_at": row["updated_at"] if row else None}
137
+ if name in PUBLIC:
138
+ item.update(value=row["value"] if row else env, choices=[{"id": i, "label": l} for i, l in MODEL_CHOICES])
139
+ out.append(item)
140
+ return out
141
+
142
+ def set(self, name: str, value: str, by: str) -> None:
143
+ """Encrypts and keeps a key that check() accepted."""
144
+ row = {"name": name, "ciphertext": self._seal(name, value), "last4": value[-4:], "updated_by": by}
145
+ self.auth.save_secret(row)
146
+ with self._lock:
147
+ self._rows[name] = {"value": value, "last4": row["last4"], "updated_by": by, "updated_at": None}
148
+
149
+ def clear(self, name: str) -> None:
150
+ """Forgets the panel's key; the environment's (if any) is used again."""
151
+ self.auth.delete_secret(name)
152
+ with self._lock:
153
+ self._rows.pop(name, None)
154
+
155
+
156
+ def check(name: str, value: str, anthropic_key: str = "") -> None:
157
+ """Tries the key with its provider, with a call that costs nothing; a tutor model, by asking Anthropic for it with
158
+ `anthropic_key` (the key in use). Raises KeyRefused."""
159
+ if name == "TUTOR_MODEL":
160
+ if not MODEL_ID.match(value):
161
+ raise KeyRefused("ืžื–ื”ื” ืžื•ื“ืœ ื ืจืื” ื›ืš: claude-haiku-5-5.")
162
+ if not anthropic_key:
163
+ raise KeyRefused("ืื™ืŸ ืžืคืชื— Anthropic, ื•ืœื›ืŸ ืื™ ืืคืฉืจ ืœื‘ื“ื•ืง ืืช ื”ืžื•ื“ืœ. ืฉืžืจื• ืงื•ื“ื ืžืคืชื—.")
164
+ import anthropic
165
+ try:
166
+ anthropic.Anthropic(api_key=anthropic_key, timeout=TIMEOUT, max_retries=0).models.retrieve(value)
167
+ except anthropic.NotFoundError:
168
+ raise KeyRefused(f"Anthropic ืœื ืžื›ื™ืจ ืืช ื”ืžื•ื“ืœ {value}.") from None
169
+ except (anthropic.AuthenticationError, anthropic.PermissionDeniedError):
170
+ raise KeyRefused("ืžืคืชื— Anthropic ืฉื‘ืฉื™ืžื•ืฉ ื ื“ื—ื”, ื•ืœื›ืŸ ืื™ ืืคืฉืจ ืœื‘ื“ื•ืง ืืช ื”ืžื•ื“ืœ.") from None
171
+ except anthropic.APIStatusError as e:
172
+ raise KeyRefused(f"ืœื ื ื™ืชืŸ ืœื‘ื“ื•ืง ืืช ื”ืžื•ื“ืœ ืžื•ืœ Anthropic ื›ืจื’ืข ({e.status_code}). ื ืกื• ืฉื•ื‘.") from None
173
+ except anthropic.APIConnectionError:
174
+ raise KeyRefused("ืื™ืŸ ื—ื™ื‘ื•ืจ ืœ-Anthropic ื›ืจื’ืข. ื ืกื• ืฉื•ื‘.") from None
175
+ return
176
+ if name == "SAWTAK_API_KEY":
177
+ req = urllib.request.Request("https://api.sawtakarabi.ai/v1/voices", headers={
178
+ "Authorization": f"Bearer {value}", "User-Agent": "ihki/1.0"})
179
+ try:
180
+ with urllib.request.urlopen(req, timeout=TIMEOUT):
181
+ return
182
+ except urllib.error.HTTPError as e:
183
+ if e.code == 403: # a valid key restricted to `tts` may not list voices; speech is all it is used for
184
+ return
185
+ if e.code == 401:
186
+ raise KeyRefused("Sawtak ื“ื—ื” ืืช ื”ืžืคืชื—.") from None
187
+ raise KeyRefused(f"ืœื ื ื™ืชืŸ ืœื‘ื“ื•ืง ืืช ื”ืžืคืชื— ืžื•ืœ Sawtak ื›ืจื’ืข ({e.code}). ื ืกื• ืฉื•ื‘.") from None
188
+ except OSError:
189
+ raise KeyRefused("ืื™ืŸ ื—ื™ื‘ื•ืจ ืœ-Sawtak ื›ืจื’ืข. ื ืกื• ืฉื•ื‘.") from None
190
+ import anthropic
191
+ try:
192
+ anthropic.Anthropic(api_key=value, timeout=TIMEOUT, max_retries=0).models.list(limit=1)
193
+ except (anthropic.AuthenticationError, anthropic.PermissionDeniedError):
194
+ raise KeyRefused("Anthropic ื“ื—ื” ืืช ื”ืžืคืชื—.") from None
195
+ except anthropic.APIStatusError as e:
196
+ raise KeyRefused(f"ืœื ื ื™ืชืŸ ืœื‘ื“ื•ืง ืืช ื”ืžืคืชื— ืžื•ืœ Anthropic ื›ืจื’ืข ({e.status_code}). ื ืกื• ืฉื•ื‘.") from None
197
+ except anthropic.APIConnectionError:
198
+ raise KeyRefused("ืื™ืŸ ื—ื™ื‘ื•ืจ ืœ-Anthropic ื›ืจื’ืข. ื ืกื• ืฉื•ื‘.") from None
199
+
200
+
201
+ def load_keys(auth) -> KeyStore:
202
+ keys = KeyStore(auth, os.environ.get("SECRETS_MASTER_KEY", ""))
203
+ keys.load()
204
+ return keys
app/main.py CHANGED
@@ -7,6 +7,7 @@ Env: MODEL_ID (default facebook/wav2vec2-xlsr-53-espeak-cv-ft, or a path to a
7
  TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, its classes and the lesson videos, see app/auth.py)
8
  TUTORS_EMAIL (more accounts that, like the teachers, flag a recording scored wrong, see app/auth.py)
9
  ANTHROPIC_API_KEY, TUTOR_MODEL (the permission-controlled AI tutor game ื™ืืœืœื” ื ื“ื‘ืจ, see app/tutor.py)
 
10
  A local .env file is loaded if present; real environment variables win over it.
11
  """
12
  from __future__ import annotations
@@ -32,6 +33,8 @@ from fastapi.staticfiles import StaticFiles
32
  from .arabic_diacritics import point_arabic
33
  from . import lab
34
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
 
 
35
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
36
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
37
  from .reference import References
@@ -41,7 +44,7 @@ from .speech_evaluation import SpeechEvaluationService
41
  from .texts import read_line, text_phrases
42
  from .tts import LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts
43
  from .tutor import (BY_ID, DAILY_LIMIT, MAX_TEXT, MAX_TURNS, SCENARIOS, Tutor, TutorLimit,
44
- TutorUnavailable, load_tutor)
45
  from .verb_game import VerbExerciseGenerator
46
 
47
  logging.basicConfig(level=logging.INFO)
@@ -384,11 +387,12 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
384
  @asynccontextmanager
385
  async def lifespan(app: FastAPI):
386
  app.state.scorer = scorer or load_scorer(MODEL_ID)
387
- app.state.tts = tts or load_tts()
388
- app.state.tutor = tutor or load_tutor()
 
 
389
  # the playback voices double as the native baseline of the scores (see reference.py)
390
  app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None
391
- app.state.auth = auth or load_auth()
392
  if app.state.auth and hasattr(app.state.auth, "calibrations"):
393
  try:
394
  rows = app.state.auth.calibrations("applied")
@@ -1973,6 +1977,91 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1973
  app.state.refs.invalidate()
1974
  return {"ok": True, "removed": removed}
1975
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1976
  @app.get("/api/prompt-audio/{phrase_id}", dependencies=[Depends(require("practice.use"))])
1977
  def prompt_audio(phrase_id: int, engine: str = "gtts"):
1978
  """The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
@@ -2023,7 +2112,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
2023
  "version": build_version()})
2024
  game_ids = {g["id"] for g in GAMES}
2025
  tasks.add_task(app.state.auth.log_event, "game" if phrase["cat"] in game_ids else "exercise", user,
2026
- phrase.get("he", ""), {"cat": phrase["cat"]})
 
2027
  return result
2028
 
2029
  @app.post("/api/flags")
 
7
  TEACHER_EMAILS, SUPABASE_SECRET_KEY (the teacher's management panel, its classes and the lesson videos, see app/auth.py)
8
  TUTORS_EMAIL (more accounts that, like the teachers, flag a recording scored wrong, see app/auth.py)
9
  ANTHROPIC_API_KEY, TUTOR_MODEL (the permission-controlled AI tutor game ื™ืืœืœื” ื ื“ื‘ืจ, see app/tutor.py)
10
+ SECRETS_MASTER_KEY (lets a developer replace the two API keys from the panel, see app/keystore.py)
11
  A local .env file is loaded if present; real environment variables win over it.
12
  """
13
  from __future__ import annotations
 
33
  from .arabic_diacritics import point_arabic
34
  from . import lab
35
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
36
+ from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
37
+ check as check_key, load_keys)
38
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
39
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
40
  from .reference import References
 
44
  from .texts import read_line, text_phrases
45
  from .tts import LINE_VOICES, RAEF_VOICE_ID, VOICES, SawtakTTS, TTSUnavailable, load_tts
46
  from .tutor import (BY_ID, DAILY_LIMIT, MAX_TEXT, MAX_TURNS, SCENARIOS, Tutor, TutorLimit,
47
+ DEFAULT_MODEL as DEFAULT_TUTOR_MODEL, TutorUnavailable, load_tutor, tutor_client)
48
  from .verb_game import VerbExerciseGenerator
49
 
50
  logging.basicConfig(level=logging.INFO)
 
387
  @asynccontextmanager
388
  async def lifespan(app: FastAPI):
389
  app.state.scorer = scorer or load_scorer(MODEL_ID)
390
+ app.state.auth = auth or load_auth()
391
+ app.state.keys = load_keys(app.state.auth) # the API keys a developer set in the panel, else the environment's
392
+ app.state.tts = tts or load_tts(app.state.keys.get("SAWTAK_API_KEY"))
393
+ app.state.tutor = tutor or load_tutor(app.state.keys.get("ANTHROPIC_API_KEY"), app.state.keys.get("TUTOR_MODEL"))
394
  # the playback voices double as the native baseline of the scores (see reference.py)
395
  app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None
 
396
  if app.state.auth and hasattr(app.state.auth, "calibrations"):
397
  try:
398
  rows = app.state.auth.calibrations("applied")
 
1977
  app.state.refs.invalidate()
1978
  return {"ok": True, "removed": removed}
1979
 
1980
+ def keys_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
1981
+ """Only developers ("*"): a key decides where the bills go. Unlike require(), an open deployment (no sign-in)
1982
+ is refused too."""
1983
+ permissions = set((user or {}).get("permissions", ()))
1984
+ if app.state.auth is None or ("*" not in permissions and "keys.manage" not in permissions):
1985
+ raise HTTPException(403, "missing permission: keys.manage")
1986
+ return app.state.auth
1987
+
1988
+ def keys_status(response: Response) -> dict:
1989
+ response.headers["Cache-Control"] = "no-store"
1990
+ keys = app.state.keys
1991
+ return {"keys": keys.status(), "writable": keys.writable, "why": keys.why,
1992
+ "setup": CREATE_APP_SECRETS if keys.missing else ""}
1993
+
1994
+ def use_key(name: str) -> None:
1995
+ """Puts the key now in force to work, without a restart. The voice cache, the scores' native baseline and the
1996
+ tutor's open conversations stay."""
1997
+ value = app.state.keys.get(name)
1998
+ if name == "TUTOR_MODEL": # open conversations go on with the new model from their next sentence
1999
+ if app.state.tutor is not None:
2000
+ app.state.tutor.model = value or DEFAULT_TUTOR_MODEL
2001
+ elif name == "SAWTAK_API_KEY":
2002
+ if app.state.tts is not None and value:
2003
+ app.state.tts.api_key = value
2004
+ else:
2005
+ app.state.tts = load_tts(value)
2006
+ app.state.refs = References(app.state.scorer, app.state.tts, list(VOICES.values())) if app.state.tts else None
2007
+ elif app.state.tutor is not None and value:
2008
+ app.state.tutor.client = tutor_client(value)
2009
+ else:
2010
+ app.state.tutor = load_tutor(value, app.state.keys.get("TUTOR_MODEL"))
2011
+
2012
+ def key_name(name: str) -> str:
2013
+ if name not in KEY_NAMES:
2014
+ raise HTTPException(404, "unknown key")
2015
+ return name
2016
+
2017
+ @app.get("/api/admin/keys")
2018
+ def api_keys(response: Response, _a: SupabaseAuth = Depends(keys_only)):
2019
+ """Which API keys are set and where from (the panel, the server's environment, or none); never the keys."""
2020
+ return keys_status(response)
2021
+
2022
+ @app.put("/api/admin/keys/{name}")
2023
+ def set_api_key(name: str, response: Response, body: dict = Body(...), _a: SupabaseAuth = Depends(keys_only),
2024
+ user: dict | None = Depends(signed_in)):
2025
+ """Tries a new key with its provider, then keeps it encrypted and puts it to work. The key is never logged."""
2026
+ name, value = key_name(name), body.get("value")
2027
+ if not isinstance(value, str) or not value.strip() or len(value.strip()) > MAX_KEY_LEN or any(c.isspace() for c in value.strip()):
2028
+ raise HTTPException(400, "a key is one word of up to 300 characters")
2029
+ if not app.state.keys.writable:
2030
+ raise HTTPException(503, app.state.keys.why or f"the table app_secrets is missing; run: {CREATE_APP_SECRETS}")
2031
+ value = value.strip()
2032
+ try:
2033
+ check_key(name, value, app.state.keys.get("ANTHROPIC_API_KEY"))
2034
+ except KeyRefused as e:
2035
+ raise HTTPException(400, str(e))
2036
+ try:
2037
+ app.state.keys.set(name, value, (user or {}).get("email", ""))
2038
+ except AuthUnavailable as e:
2039
+ if any(mark in str(e).lower() for mark in ("check constraint", "23514")): # a table made before TUTOR_MODEL
2040
+ raise HTTPException(409, f"ื”ื˜ื‘ืœื” app_secrets ืฆืจื™ื›ื” ืขื“ื›ื•ืŸ; ื”ืจื™ืฆื• ื‘-Supabase: {MIGRATE_APP_SECRETS}")
2041
+ raise HTTPException(503, f"Supabase ืœื ื–ืžื™ืŸ: {e}")
2042
+ use_key(name)
2043
+ log.info("API key %s replaced by %s", name, (user or {}).get("email", "?"))
2044
+ if app.state.auth:
2045
+ app.state.auth.log_event("permission", user, f"ืžืคืชื— API: {name}", {"key": name, "action": "set"})
2046
+ return keys_status(response)
2047
+
2048
+ @app.delete("/api/admin/keys/{name}")
2049
+ def clear_api_key(name: str, response: Response, _a: SupabaseAuth = Depends(keys_only),
2050
+ user: dict | None = Depends(signed_in)):
2051
+ """Forgets the panel's key: the server's environment key (if any) is used again."""
2052
+ name = key_name(name)
2053
+ if not app.state.keys.writable:
2054
+ raise HTTPException(503, app.state.keys.why or "the table app_secrets is missing")
2055
+ try:
2056
+ app.state.keys.clear(name)
2057
+ except AuthUnavailable as e:
2058
+ raise HTTPException(503, f"Supabase ืœื ื–ืžื™ืŸ: {e}")
2059
+ use_key(name)
2060
+ log.info("API key %s cleared by %s", name, (user or {}).get("email", "?"))
2061
+ if app.state.auth:
2062
+ app.state.auth.log_event("permission", user, f"ืžืคืชื— API: {name}", {"key": name, "action": "clear"})
2063
+ return keys_status(response)
2064
+
2065
  @app.get("/api/prompt-audio/{phrase_id}", dependencies=[Depends(require("practice.use"))])
2066
  def prompt_audio(phrase_id: int, engine: str = "gtts"):
2067
  """The Hebrew prompt read aloud (edge-tts or gTTS). 503 tells the page to stay silent."""
 
2112
  "version": build_version()})
2113
  game_ids = {g["id"] for g in GAMES}
2114
  tasks.add_task(app.state.auth.log_event, "game" if phrase["cat"] in game_ids else "exercise", user,
2115
+ phrase.get("he", ""), {"cat": phrase["cat"], "score": round(result["score"]),
2116
+ "flags": len(issues), "unrelated": bool(result.get("unrelated"))})
2117
  return result
2118
 
2119
  @app.post("/api/flags")
app/rules.py CHANGED
@@ -14,6 +14,7 @@ with ส matches a model that heard ษฃ. check_detectable() tells which rules a mo
14
  from __future__ import annotations
15
 
16
  import re
 
17
  from dataclasses import dataclass, field
18
  from functools import lru_cache
19
  from pathlib import Path
@@ -224,8 +225,13 @@ def elided_article(toks, i: int, ar_words: list[str]) -> bool:
224
 
225
  # A glottal stop that is simply not there is how natives say it too (ุงู†ุง, ุงู‡ู„ุง; ู‚ between vowels): #1, #2.
226
  # A glottal stop replaced by another consonant still counts, unless the class accepts it (ู‚ said as k).
 
 
227
  def _glottal_dropped(toks, i, sc, heard, ar_words):
228
- return sc.deleted and toks[i].sound in ("ส”", "Q")
 
 
 
229
 
230
 
231
  def _article_vowel_elided(toks, i, sc, heard, ar_words): # #3
 
14
  from __future__ import annotations
15
 
16
  import re
17
+ import math
18
  from dataclasses import dataclass, field
19
  from functools import lru_cache
20
  from pathlib import Path
 
225
 
226
  # A glottal stop that is simply not there is how natives say it too (ุงู†ุง, ุงู‡ู„ุง; ู‚ between vowels): #1, #2.
227
  # A glottal stop replaced by another consonant still counts, unless the class accepts it (ู‚ said as k).
228
+ # At utterance onset, though, one weak competitor frame is commonly a breath or microphone transient. Treat that
229
+ # as an omission regardless of the phone the multilingual model names; two frames (~40 ms) remain a substitution.
230
  def _glottal_dropped(toks, i, sc, heard, ar_words):
231
+ if toks[i].sound not in ("ส”", "Q"):
232
+ return False
233
+ transient_onset = i == 0 and sc.peak < math.log(0.05) and sc.comp_frames < 2
234
+ return sc.deleted or transient_onset
235
 
236
 
237
  def _article_vowel_elided(toks, i, sc, heard, ar_words): # #3
app/tts.py CHANGED
@@ -125,8 +125,9 @@ class SawtakTTS:
125
  return data, voice
126
 
127
 
128
- def load_tts() -> SawtakTTS | None:
129
- key = os.environ.get("SAWTAK_API_KEY", "").strip()
 
130
  if not key:
131
  log.info("SAWTAK_API_KEY not set: playback uses the device voice")
132
  return None
 
125
  return data, voice
126
 
127
 
128
+ def load_tts(key: str | None = None) -> SawtakTTS | None:
129
+ """`key`: the one the panel set (see keystore.py); None reads the environment's."""
130
+ key = (os.environ.get("SAWTAK_API_KEY", "") if key is None else key).strip()
131
  if not key:
132
  log.info("SAWTAK_API_KEY not set: playback uses the device voice")
133
  return None
app/tutor.py CHANGED
@@ -18,6 +18,7 @@ this process's memory while it runs (a restart ends it) and is kept, turn by tur
18
  Env: ANTHROPIC_API_KEY (or CLAUDE_API_KEY) required; without it the game is not offered
19
  Access is granted per account in the management panel; an open service remains open.
20
  TUTOR_MODEL optional: the Claude model the tutor runs on (default DEFAULT_MODEL), e.g. claude-sonnet-5-5
 
21
  """
22
  from __future__ import annotations
23
 
@@ -324,10 +325,40 @@ class Chat:
324
  "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
325
 
326
 
327
- def thinking_for(model: str) -> dict:
328
- """No thinking for a chat answer: Haiku turns it off with "disabled", Sonnet 5.5 (which refuses that) with
329
- "between_tools"."""
330
- return {"type": "between_tools"} if model.startswith("claude-sonnet-5-5") else {"type": "disabled"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
 
333
  class Tutor:
@@ -404,8 +435,8 @@ class Tutor:
404
  last = chat.turns + 1 >= MAX_TURNS
405
  messages = chat.messages + [{"role": "user", "content": text}]
406
  if last:
407
- messages.append({"role": "system", "content": "This is the student's last sentence. Answer it and say "
408
- "goodbye warmly, and write the summary."})
409
  data = self._answer(chat, messages)
410
  chat.turns += 1
411
  chat.done = last
@@ -424,6 +455,7 @@ class Tutor:
424
  def _answer(self, chat: Chat, messages: list[dict]) -> dict:
425
  """Asks the model and keeps the exchange in the conversation's history (only once it has answered)."""
426
  data, usage = self._ask(chat.system, messages)
 
427
  chat.messages = messages + [{"role": "assistant", "content": json.dumps(data, ensure_ascii=False)}]
428
  chat.seen = time.monotonic()
429
  chat.input_tokens += usage[0]
@@ -434,10 +466,8 @@ class Tutor:
434
  import anthropic
435
  try:
436
  res = self.client.messages.create(
437
- model=self.model, max_tokens=4000, system=system, messages=messages,
438
- thinking=thinking_for(self.model),
439
- output_config={"effort": "low", "format": {"type": "json_schema", "schema": REPLY_SCHEMA}},
440
- cache_control={"type": "ephemeral"},
441
  )
442
  except anthropic.RateLimitError as e:
443
  log.warning("tutor rate limited: %s", e)
@@ -476,8 +506,16 @@ class Tutor:
476
  self._started.clear()
477
 
478
 
479
- def load_tutor() -> Tutor | None:
480
- key = (os.environ.get("ANTHROPIC_API_KEY", "") or os.environ.get("CLAUDE_API_KEY", "")).strip()
 
 
 
 
 
 
 
 
481
  if not key:
482
  log.info("no ANTHROPIC_API_KEY: the tutor game (ื™ืืœืœื” ื ื“ื‘ืจ) is off")
483
  return None
@@ -486,9 +524,9 @@ def load_tutor() -> Tutor | None:
486
  except ImportError:
487
  log.warning("ANTHROPIC_API_KEY is set but the anthropic package is not installed: the tutor game is off")
488
  return None
489
- model = os.environ.get("TUTOR_MODEL", "").strip() or DEFAULT_MODEL
490
  log.info("the tutor game (ื™ืืœืœื” ื ื“ื‘ืจ) runs on %s", model)
491
- return Tutor(anthropic.Anthropic(api_key=key, timeout=30.0, max_retries=2), model)
492
 
493
 
494
  def tutor_open() -> bool:
 
18
  Env: ANTHROPIC_API_KEY (or CLAUDE_API_KEY) required; without it the game is not offered
19
  Access is granted per account in the management panel; an open service remains open.
20
  TUTOR_MODEL optional: the Claude model the tutor runs on (default DEFAULT_MODEL), e.g. claude-sonnet-5-5
21
+ (request_options() fits the request to it: Haiku 5.5/4.5, Sonnet 5.5, Opus 5.5 and Fable 5.1 all run)
22
  """
23
  from __future__ import annotations
24
 
 
325
  "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
326
 
327
 
328
+ # models that take a `role: "system"` message in the middle of `messages` (the others answer 400)
329
+ SYSTEM_MESSAGES = ("claude-opus-5", "claude-opus-4-8", "claude-fable-5", "claude-mythos-5", "claude-sonnet-5-5", "claude-haiku-5-5")
330
+
331
+
332
+ def thinking_for(model: str) -> dict | None:
333
+ """As little thinking as each model allows for a chat answer. Sonnet 5.5 refuses "disabled" and turns thinking off
334
+ with "between_tools"; Opus 5.5 and Fable 5.x cannot turn it off at all (None: the parameter is left out, so it is
335
+ adaptive, and effort "low" keeps it short); the others take "disabled". Only the answer's text goes back into the
336
+ history, never a thinking block, so the history stays append-only and nothing is checked against it."""
337
+ if model.startswith("claude-sonnet-5-5"):
338
+ return {"type": "between_tools"}
339
+ if model.startswith(("claude-opus-5-5", "claude-fable-5", "claude-mythos-5")):
340
+ return None
341
+ return {"type": "disabled"}
342
+
343
+
344
+ def request_options(model: str) -> dict:
345
+ """The arguments of a chat answer that differ by model: thinking (see thinking_for), effort (Haiku 4.5 has none),
346
+ and room for a thinking model to think before it answers."""
347
+ thinking = thinking_for(model)
348
+ config = {"format": {"type": "json_schema", "schema": REPLY_SCHEMA}}
349
+ if not model.startswith("claude-haiku-4-5"):
350
+ config["effort"] = "low"
351
+ return {"max_tokens": 4000 if thinking else 8000, "output_config": config, **({"thinking": thinking} if thinking else {})}
352
+
353
+
354
+ def last_turn(model: str, messages: list[dict], note: str) -> list[dict]:
355
+ """Tells the model this is the last sentence: as a system message where the model takes one, else after the
356
+ student's sentence in the same user turn."""
357
+ if model.startswith(SYSTEM_MESSAGES):
358
+ return messages + [{"role": "system", "content": note}]
359
+ *before, student = messages
360
+ return before + [{"role": "user", "content": [{"type": "text", "text": student["content"]},
361
+ {"type": "text", "text": f"(Instruction: {note})"}]}]
362
 
363
 
364
  class Tutor:
 
435
  last = chat.turns + 1 >= MAX_TURNS
436
  messages = chat.messages + [{"role": "user", "content": text}]
437
  if last:
438
+ messages = last_turn(self.model, messages, "This is the student's last sentence. Answer it and say "
439
+ "goodbye warmly, and write the summary.")
440
  data = self._answer(chat, messages)
441
  chat.turns += 1
442
  chat.done = last
 
455
  def _answer(self, chat: Chat, messages: list[dict]) -> dict:
456
  """Asks the model and keeps the exchange in the conversation's history (only once it has answered)."""
457
  data, usage = self._ask(chat.system, messages)
458
+ chat.model = self.model # the panel may have changed it since the conversation began
459
  chat.messages = messages + [{"role": "assistant", "content": json.dumps(data, ensure_ascii=False)}]
460
  chat.seen = time.monotonic()
461
  chat.input_tokens += usage[0]
 
466
  import anthropic
467
  try:
468
  res = self.client.messages.create(
469
+ model=self.model, system=system, messages=messages, cache_control={"type": "ephemeral"},
470
+ **request_options(self.model),
 
 
471
  )
472
  except anthropic.RateLimitError as e:
473
  log.warning("tutor rate limited: %s", e)
 
506
  self._started.clear()
507
 
508
 
509
+ def tutor_client(key: str):
510
+ import anthropic
511
+ return anthropic.Anthropic(api_key=key, timeout=30.0, max_retries=2)
512
+
513
+
514
+ def load_tutor(key: str | None = None, model: str | None = None) -> Tutor | None:
515
+ """`key`, `model`: the ones the panel set (see keystore.py); None reads the environment's."""
516
+ if key is None:
517
+ key = os.environ.get("ANTHROPIC_API_KEY", "") or os.environ.get("CLAUDE_API_KEY", "")
518
+ key = key.strip()
519
  if not key:
520
  log.info("no ANTHROPIC_API_KEY: the tutor game (ื™ืืœืœื” ื ื“ื‘ืจ) is off")
521
  return None
 
524
  except ImportError:
525
  log.warning("ANTHROPIC_API_KEY is set but the anthropic package is not installed: the tutor game is off")
526
  return None
527
+ model = (os.environ.get("TUTOR_MODEL", "") if model is None else model).strip() or DEFAULT_MODEL
528
  log.info("the tutor game (ื™ืืœืœื” ื ื“ื‘ืจ) runs on %s", model)
529
+ return Tutor(tutor_client(key), model)
530
 
531
 
532
  def tutor_open() -> bool:
app/workbook.py CHANGED
@@ -4115,8 +4115,14 @@ def workbook_phrases() -> list[dict]:
4115
  who = said = m[1]
4116
  he, sentence = he[m.end():], SPEAKER.sub("", sentence)
4117
  words = [_word(t) for t in sentence.split()]
 
 
 
 
 
 
4118
  phrase = dict(cat=f"l{lesson_id(*key)}-{tid}", he=he, tr=" ".join(t for _, t, _ in words),
4119
- variants=_variants(words), read=kind == "read", **extra)
4120
  say = _voiced(he_say[0] if he_say else he)
4121
  if say != he:
4122
  phrase["he_say"] = say
 
4115
  who = said = m[1]
4116
  he, sentence = he[m.end():], SPEAKER.sub("", sentence)
4117
  words = [_word(t) for t in sentence.split()]
4118
+ variants = _variants(words)
4119
+ # These vocabulary prompts teach two synonymous words. Saying both remains valid, but unlike
4120
+ # conjugation and singular/dual pairs, either synonym on its own is also a complete answer.
4121
+ if "(ืฉืชื™ ืžื™ืœื™ื)" in he:
4122
+ singles = [(a.rstrip("".join(PUNCT)), tr.rstrip("".join(PUNCT.values())), ipas) for a, tr, ipas in words]
4123
+ variants += [variant for word in singles for variant in _variants([word]) if variant not in variants]
4124
  phrase = dict(cat=f"l{lesson_id(*key)}-{tid}", he=he, tr=" ".join(t for _, t, _ in words),
4125
+ variants=variants, read=kind == "read", **extra)
4126
  say = _voiced(he_say[0] if he_say else he)
4127
  if say != he:
4128
  phrase["he_say"] = say
requirements.txt CHANGED
@@ -8,6 +8,7 @@ huggingface_hub>=0.23
8
  # torch: CPU build installed separately in the Dockerfile (see README for local installs)
9
  python-dotenv>=1.0
10
  anthropic>=1.0 # the AI tutor game ื‘ื—ื›ื™ ืขืจื‘ื™ (app/tutor.py)
 
11
  edge-tts>=7.0
12
  gTTS>=2.5
13
  tzdata>=2024.1 # time zones for zoneinfo: the slim image may not carry the system ones
 
8
  # torch: CPU build installed separately in the Dockerfile (see README for local installs)
9
  python-dotenv>=1.0
10
  anthropic>=1.0 # the AI tutor game ื‘ื—ื›ื™ ืขืจื‘ื™ (app/tutor.py)
11
+ cryptography>=42 # the API keys set in the panel are kept encrypted (app/keystore.py)
12
  edge-tts>=7.0
13
  gTTS>=2.5
14
  tzdata>=2024.1 # time zones for zoneinfo: the slim image may not carry the system ones
static/index.html CHANGED
@@ -752,7 +752,14 @@ body.manage #manage{width:100%;max-width:none}
752
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
753
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
754
  .activity-chart{width:100%;height:190px;overflow:visible}.activity-chart .grid{stroke:var(--line);stroke-width:1}.activity-chart .bar{fill:var(--olive);opacity:.82}.activity-chart .bar:hover{opacity:1}.activity-chart text{fill:var(--muted);font:11px var(--f-body)}
755
- .system-feed{display:flex;flex-direction:column;gap:0}.system-event{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.system-event:last-child{border-bottom:0}.system-event i{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--olive-soft);font-style:normal}.system-event b,.system-event small{display:block}.system-event small,.system-event time{color:var(--muted);font-size:12px}.system-event time{direction:ltr;white-space:nowrap}
 
 
 
 
 
 
 
756
  @media(max-width:560px){.activity-stats{grid-template-columns:1fr}.activity-chart{height:160px}}
757
  /* the content part: a level's name, then a card per lesson with its topics and videos, a row each, and the form that adds a video */
758
  #mContent{display:flex;flex-direction:column;gap:12px}
@@ -4413,6 +4420,7 @@ function drawScoringRules(box){
4413
  function drawFlags(){
4414
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
4415
  drawActivity(box);
 
4416
  drawSystemActivity(box);
4417
  drawTtsCacheControls(box);
4418
  if(!flagsLoaded){ box.append(mark("card","ื‘ื—ืจื• ื‘ื ื™ื˜ื•ืจ ื›ื“ื™ ืœื˜ืขื•ืŸ ืืช ื”ื ืชื•ื ื™ื.")); return; }
@@ -4515,14 +4523,41 @@ function drawFlags(){
4515
  confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,diagnose,confirm,reject,msg); card.append(note,actions); box.append(card);
4516
  }
4517
  }
 
4518
  function drawSystemActivity(box){
4519
- const card=mark("card",""), title=mark("","ืคืขื™ืœื•ืช ืื—ืจื•ื ื” ื‘ืžืขืจื›ืช","h2"); card.append(title); box.append(card);
 
 
4520
  if(systemActivityError){ card.append(mark("note","ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืคืขื™ืœื•ืช ืื—ืจื•ื ื”: "+systemActivityError,"p")); return; }
4521
  if(!SYSTEM_ACTIVITY){ card.append(mark("note","ื˜ื•ืขืŸ ืคืขื™ืœื•ืชโ€ฆ","p")); return; }
4522
  if(SYSTEM_ACTIVITY.setup){ const code=mark("",SYSTEM_ACTIVITY.setup,"code"); code.dir="ltr"; card.append(mark("setup","ื›ื“ื™ ืœื”ืชื—ื™ืœ ืœืฉืžื•ืจ ืคืขื™ืœื•ืช, ื™ืฉ ืœื™ืฆื•ืจ ืืช ื”ื˜ื‘ืœื” system_events ื‘ึพSupabase:","p"),code); return; }
4523
  const events=SYSTEM_ACTIVITY.events||[], labels={exercise:["๐ŸŽ™๏ธ","ื”ื›ื ืช ืชืจื’ื™ืœื™ื"],game:["๐ŸŽฎ","ืžืฉื—ืงื™ื"],permission:["๐Ÿ”","ืฉื™ื ื•ื™ ื”ืจืฉืื•ืช"],content:["โž•","ื”ื•ืกืคืช ืชื›ื ื™ื"]}, feed=mark("system-feed","");
4524
  if(!events.length){ card.append(mark("note","ืขื“ื™ื™ืŸ ืœื ื ืจืฉืžื” ืคืขื™ืœื•ืช.","p")); return; }
4525
- for(const event of events){ const [icon,kind]=labels[event.kind]||["โ€ข","ืคืขื™ืœื•ืช"], row=mark("system-event",""), text=document.createElement("div"), time=document.createElement("time"), when=new Date(event.created_at); text.append(mark("",kind,"b"),mark("",[event.actor_email,event.label].filter(Boolean).join(" ยท ")||"ืคืขื™ืœื•ืช ื‘ืžืขืจื›ืช","small")); time.dateTime=event.created_at; time.textContent=when.toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Jerusalem"}); row.append(mark("",icon,"i"),text,time); feed.append(row); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4526
  card.append(feed);
4527
  }
4528
  function drawTtsCacheControls(box){
@@ -4542,6 +4577,119 @@ function drawTtsCacheControls(box){
4542
  };
4543
  row.append(button,msg); card.append(title,text,row); box.append(card);
4544
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4545
  function drawActivity(box){
4546
  const days=activityHours===168, range=days?"7 ื”ื™ืžื™ื ื”ืื—ืจื•ื ื™ื":"24 ื”ืฉืขื•ืช ื”ืื—ืจื•ื ื•ืช", bucket=days?"ืฉืขื”":"15 ื“ืงื•ืช";
4547
  const card=mark("card activity",""), head=mark("activity-head",""), title=mark("",`ืคืขื™ืœื•ืช ื‘ึพ${range}`,"h2"), sub=mark("",`ื›ืœ ืขืžื•ื“ื” ืžื™ื™ืฆื’ืช ${bucket} ยท ืžืชืขื“ื›ืŸ ืคืขื ื‘ื“ืงื”`,"p");
 
752
  .activity-head select{font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
753
  .activity-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.activity-stat{padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}.activity-stat b{display:block;font:400 28px/1 var(--f-display);font-variant-numeric:tabular-nums}.activity-stat small{color:var(--muted);font-size:12px}
754
  .activity-chart{width:100%;height:190px;overflow:visible}.activity-chart .grid{stroke:var(--line);stroke-width:1}.activity-chart .bar{fill:var(--olive);opacity:.82}.activity-chart .bar:hover{opacity:1}.activity-chart text{fill:var(--muted);font:11px var(--f-body)}
755
+ .system-activity summary{cursor:pointer}.system-activity summary h2{display:inline;margin:0}.system-activity details[open]>summary{margin-bottom:8px}
756
+ .system-hard{margin:2px 0 4px;padding:10px;border-radius:10px;background:var(--bg)}.system-hard h3{margin:0 0 6px;font:700 15px/1.3 var(--f-body)}.system-hard-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}.system-hard-row:last-child{border-bottom:0}.system-hard-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.system-hard-row small{color:var(--muted);white-space:nowrap}.system-feed{display:flex;flex-direction:column;gap:14px}.system-topic{border-top:1px solid var(--line);padding-top:10px}.system-topic:first-child{border-top:0;padding-top:0}.system-topic h3{margin:0 0 3px;font:700 15px/1.3 var(--f-body)}.system-topic h3 small{color:var(--muted);font-weight:600}.system-event{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line)}.system-event:last-child{border-bottom:0}.system-event i{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--olive-soft);font-style:normal}.system-event b,.system-event small{display:block}.system-event small,.system-event time{color:var(--muted);font-size:12px}.system-event time{direction:ltr;white-space:nowrap}
757
+ /* the API keys card (developers only): a row per key, where it comes from and a field for a new one; a key is never shown */
758
+ .key-row{display:flex;flex-direction:column;gap:8px;padding:10px 0;border-bottom:1px solid var(--line)}.key-row:last-child{border-bottom:0}.key-row b,.key-row small{display:block}.key-row small{color:var(--muted);font-size:12px}
759
+ .key-guide summary{cursor:pointer;color:var(--olive);font-size:14px;font-weight:600}.key-guide ol{margin:8px 0 4px;padding-inline-start:22px;display:flex;flex-direction:column;gap:5px;font-size:14px;line-height:1.5}.key-guide a{color:var(--olive);font-weight:600}.key-cost{margin-top:10px;padding:10px;border-radius:10px;background:var(--bg);font-size:14px;line-height:1.5}.key-cost p{margin:4px 0 8px}.key-cost ul{margin:6px 0 0;padding-inline-start:20px;display:flex;flex-direction:column;gap:4px;color:var(--muted);font-size:13px}
760
+ .key-cost-table{overflow-x:auto}.key-cost table{border-collapse:collapse;width:100%;font-size:13px}.key-cost th,.key-cost td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:start;white-space:nowrap}.key-cost th{color:var(--muted);font-weight:600}.key-cost td[dir=ltr]{font-variant-numeric:tabular-nums}
761
+ .key-guide .note{margin:4px 0 0;color:var(--muted);font-size:13px}
762
+ .key-row form{display:flex;flex-wrap:wrap;gap:8px;align-items:center}.key-row select{flex:1 1 220px;min-width:0;font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}.key-row input[hidden]{display:none}.key-row input{flex:1 1 220px;min-width:0;font:inherit;padding:7px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink)}
763
  @media(max-width:560px){.activity-stats{grid-template-columns:1fr}.activity-chart{height:160px}}
764
  /* the content part: a level's name, then a card per lesson with its topics and videos, a row each, and the form that adds a video */
765
  #mContent{display:flex;flex-direction:column;gap:12px}
 
4420
  function drawFlags(){
4421
  const box=$("mFlags"); box.innerHTML=""; for(const url of flagAudioUrls) URL.revokeObjectURL(url); flagAudioUrls=[];
4422
  drawActivity(box);
4423
+ drawApiKeys(box);
4424
  drawSystemActivity(box);
4425
  drawTtsCacheControls(box);
4426
  if(!flagsLoaded){ box.append(mark("card","ื‘ื—ืจื• ื‘ื ื™ื˜ื•ืจ ื›ื“ื™ ืœื˜ืขื•ืŸ ืืช ื”ื ืชื•ื ื™ื.")); return; }
 
4523
  confirm.onclick=()=>save("confirmed"); reject.onclick=()=>save("rejected"); actions.append(category,diagnose,confirm,reject,msg); card.append(note,actions); box.append(card);
4524
  }
4525
  }
4526
+ let systemActivityOpen=false; // the recent activity starts closed; a redraw keeps it as the teacher left it
4527
  function drawSystemActivity(box){
4528
+ const outer=mark("card system-activity",""), card=document.createElement("details"), summary=document.createElement("summary");
4529
+ summary.append(mark("","ืคืขื™ืœื•ืช ืื—ืจื•ื ื” ื‘ืžืขืจื›ืช","h2")); card.open=systemActivityOpen; card.ontoggle=()=>{ systemActivityOpen=card.open; };
4530
+ card.append(summary); outer.append(card); box.append(outer);
4531
  if(systemActivityError){ card.append(mark("note","ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืคืขื™ืœื•ืช ืื—ืจื•ื ื”: "+systemActivityError,"p")); return; }
4532
  if(!SYSTEM_ACTIVITY){ card.append(mark("note","ื˜ื•ืขืŸ ืคืขื™ืœื•ืชโ€ฆ","p")); return; }
4533
  if(SYSTEM_ACTIVITY.setup){ const code=mark("",SYSTEM_ACTIVITY.setup,"code"); code.dir="ltr"; card.append(mark("setup","ื›ื“ื™ ืœื”ืชื—ื™ืœ ืœืฉืžื•ืจ ืคืขื™ืœื•ืช, ื™ืฉ ืœื™ืฆื•ืจ ืืช ื”ื˜ื‘ืœื” system_events ื‘ึพSupabase:","p"),code); return; }
4534
  const events=SYSTEM_ACTIVITY.events||[], labels={exercise:["๐ŸŽ™๏ธ","ื”ื›ื ืช ืชืจื’ื™ืœื™ื"],game:["๐ŸŽฎ","ืžืฉื—ืงื™ื"],permission:["๐Ÿ”","ืฉื™ื ื•ื™ ื”ืจืฉืื•ืช"],content:["โž•","ื”ื•ืกืคืช ืชื›ื ื™ื"]}, feed=mark("system-feed","");
4535
  if(!events.length){ card.append(mark("note","ืขื“ื™ื™ืŸ ืœื ื ืจืฉืžื” ืคืขื™ืœื•ืช.","p")); return; }
4536
+ const difficult=new Map;
4537
+ for(const event of events){
4538
+ const details=event.details||{}, score=+details.score;
4539
+ if(event.kind!=="exercise"||!event.label||!Number.isFinite(score)||details.unrelated) continue;
4540
+ const key=`${details.cat||""}\n${event.label}`, row=difficult.get(key)||{label:event.label,scores:[],flags:0,students:new Set};
4541
+ row.scores.push(score); row.flags+=Number.isFinite(+details.flags)?+details.flags:0; if(event.actor_email) row.students.add(event.actor_email); difficult.set(key,row);
4542
+ }
4543
+ const hardest=[...difficult.values()].map(x=>({...x,average:Math.round(x.scores.reduce((a,b)=>a+b,0)/x.scores.length)})).sort((a,b)=>a.average-b.average||b.scores.length-a.scores.length).slice(0,5);
4544
+ if(hardest.length){
4545
+ const hard=mark("system-hard",""), heading=mark("","ื”ืชืจื’ื™ืœื™ื ื”ืžืืชื’ืจื™ื ื‘ื™ื•ืชืจ","h3"); hard.append(heading);
4546
+ for(const row of hardest){ const line=mark("system-hard-row",""), attempts=row.scores.length, students=row.students.size; line.append(mark("",row.label,"span"),mark("",`ืžืžื•ืฆืข ${row.average} ยท ${attempts} ื ื™ืกื™ื•ื ื•ืช ยท ${students} ืชืœืžื™ื“ื™ื ยท ${row.flags} ื“ื’ืœื•ื ื™ื`,"small")); hard.append(line); }
4547
+ card.append(hard);
4548
+ }
4549
+ const groups=new Map;
4550
+ for(const event of events){
4551
+ const details=event.details||{}, topic=ALL_CATS.find(c=>c.id===details.cat)||GAMES.find(g=>g.id===details.cat), fallback=(labels[event.kind]||["","ืคืขื™ืœื•ืช"])[1], key=details.cat?`topic:${details.cat}`:`kind:${event.kind}`;
4552
+ if(!groups.has(key)) groups.set(key,{name:(topic&&topic.name)||fallback,events:[],flags:0});
4553
+ const group=groups.get(key); group.events.push(event); group.flags+=Number.isFinite(+details.flags)?+details.flags:0;
4554
+ }
4555
+ for(const group of groups.values()){
4556
+ const section=mark("system-topic",""), heading=mark("",group.name,"h3");
4557
+ heading.append(" ",mark("",`(${group.events.length} ืจืฉื•ืžื•ืช ยท ${group.flags} ื“ื’ืœื•ื ื™ื)`,"small")); section.append(heading);
4558
+ for(const event of group.events){ const [icon,kind]=labels[event.kind]||["โ€ข","ืคืขื™ืœื•ืช"], row=mark("system-event",""), text=document.createElement("div"), time=document.createElement("time"), when=new Date(event.created_at); text.append(mark("",kind,"b"),mark("",[event.actor_email,event.label].filter(Boolean).join(" ยท ")||"ืคืขื™ืœื•ืช ื‘ืžืขืจื›ืช","small")); time.dateTime=event.created_at; time.textContent=when.toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Jerusalem"}); row.append(mark("",icon,"i"),text,time); section.append(row); }
4559
+ feed.append(section);
4560
+ }
4561
  card.append(feed);
4562
  }
4563
  function drawTtsCacheControls(box){
 
4577
  };
4578
  row.append(button,msg); card.append(title,text,row); box.append(card);
4579
  }
4580
+ // how to get each key, step by step: a step is its parts, text or [text, link]
4581
+ const KEY_GUIDES={
4582
+ SAWTAK_API_KEY:[
4583
+ ["ื”ื™ื›ื ืกื• ืœึพ",["sawtakarabi.ai/dashboard/api-keys","https://sawtakarabi.ai/dashboard/api-keys"]," ื•ื”ืชื—ื‘ืจื• (ืื• ื”ื™ืจืฉืžื•, ืื ืขื“ื™ื™ืŸ ืื™ืŸ ื—ืฉื‘ื•ืŸ)."],
4584
+ ["ืœื—ืฆื• ืขืœ Create key, ืชื ื• ืœืžืคืชื— ืฉื ืฉืชื–ื”ื• (ืœืžืฉืœ ihki) ื•ืฆืจื• ืื•ืชื•."],
4585
+ ["ื”ืขืชื™ืงื• ืืช ื”ืžืคืชื— ืžื™ื“: ื”ื•ื ืžื•ืฆื’ ืคืขื ืื—ืช ื‘ืœื‘ื“. ืื ื”ื•ื ืื‘ื“, ืฆืจื• ืžืคืชื— ื—ื“ืฉ."],
4586
+ ["ื•ื“ืื• ืฉื™ืฉ ื™ืชืจื” ื‘ื—ืฉื‘ื•ืŸ ื‘ึพ",["Billing","https://sawtakarabi.ai/dashboard/billing"],". ื‘ืœื™ ื™ืชืจื” ื”ื”ืฉืžืขื” ื ื›ืฉืœืช ื•ื”ื“ืฃ ืขื•ื‘ืจ ืœืงื•ืœ ืฉืœ ื”ืžื›ืฉื™ืจ."],
4587
+ ["ื”ื“ื‘ื™ืงื• ืืช ื”ืžืคืชื— ื›ืืŸ ื•ืœื—ืฆื• ืฉืžื™ืจื”. ืžืคืชื— ืฉื ื•ืฆืจ ื‘ื“ืฉื‘ื•ืจื“ ืžืชืื™ื ื›ืžื• ืฉื”ื•ื; ืžืคืชื— ืžื•ื’ื‘ืœ ืฆืจื™ืš ืœืคื—ื•ืช ืืช ื”ื”ืจืฉืื” tts."],
4588
+ ["ืื—ืจื™ ืฉื”ืžืคืชื— ื”ื—ื“ืฉ ืขื•ื‘ื“, ืžื—ืงื• ืืช ื”ื™ืฉืŸ ื‘ื“ืฉื‘ื•ืจื“ ืฉืœ Sawtak."]],
4589
+ ANTHROPIC_API_KEY:[
4590
+ ["ื”ื™ื›ื ืกื• ืœึพ",["platform.claude.com","https://platform.claude.com/"]," (ื”ึพConsole ืฉืœ Anthropic, ืœืฉืขื‘ืจ console.anthropic.com) ื•ื”ืชื—ื‘ืจื• ืื• ื”ื™ืจืฉืžื•."],
4591
+ ["ื˜ืขื ื• ืงืจื“ื™ื˜ ื‘ึพ",["Billing","https://platform.claude.com/settings/billing"],". ื”ึพAPI ืžื—ื•ื™ื‘ ื‘ื ืคืจื“ ืžืžื ื•ื™ Claude.ai, ื›ืš ืฉืžื ื•ื™ ืœื‘ื“ื• ืœื ืžืกืคื™ืง."],
4592
+ ["ืขื‘ืจื• ืœึพ",["API keys","https://platform.claude.com/settings/keys"],", ืœื—ืฆื• Create key, ืชื ื• ืฉื (ืœืžืฉืœ ihki) ื•ื‘ื—ืจื• workspace (ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ืžืชืื™ืžื”)."],
4593
+ ["ื”ืขืชื™ืงื• ืืช ื”ืžืคืชื— ืžื™ื“ (ื”ื•ื ืžืชื—ื™ืœ ื‘ึพsk-ant-): ื”ื•ื ืžื•ืฆื’ ืคืขื ืื—ืช ื‘ืœื‘ื“."],
4594
+ ["ืžื•ืžืœืฅ: ืงื‘ืขื• ืชืงืจืช ื”ื•ืฆืื” ื—ื•ื“ืฉื™ืช ื‘ึพ",["Limits","https://platform.claude.com/settings/limits"],", ื›ื“ื™ ืฉืชืงืœื” ืื• ืฉื™ืžื•ืฉ ื—ืจื™ื’ ืœื ื™ืขืœื• ื™ื•ืชืจ ืžืžื” ืฉืชื›ื ื ืชื."],
4595
+ ["ื”ื“ื‘ื™ืงื• ืืช ื”ืžืคืชื— ื›ืืŸ ื•ืœื—ืฆื• ืฉืžื™ืจื”. ืื—ืจื™ ืฉื”ื•ื ืขื•ื‘ื“, ืžื—ืงื• ืืช ื”ืžืคืชื— ื”ื™ืฉืŸ ื‘ึพConsole."]],
4596
+ TUTOR_MODEL:[
4597
+ ["ื‘ื—ืจื• ืžื•ื“ืœ ืžื”ืจืฉื™ืžื”. Haiku 5.5 ื”ื•ื ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ื•ื”ื–ื•ืœ ื‘ื™ื•ืชืจ; ื”ืฉื•ื•ืืช ื”ืขืœื•ื™ื•ืช ื ืžืฆืืช ื‘ื”ืขืจื›ืช ื”ืขืœื•ืช ืฉืœ ืžืคืชื— Anthropic ืœืžืขืœื”."],
4598
+ ["ืœืžื•ื“ืœ ืฉืœื ื‘ืจืฉื™ืžื” ื‘ื—ืจื• \"ืื—ืจ\" ื•ื”ืงืœื™ื“ื• ืืช ื”ืžื–ื”ื” ืฉืœื• (ืœืžืฉืœ claude-sonnet-5-5). ื”ืžื–ื”ื™ื ืžื•ืคื™ืขื™ื ื‘ึพ",["ืจืฉื™ืžืช ื”ืžื•ื“ืœื™ื ืฉืœ Anthropic","https://platform.claude.com/docs/en/models/overview"],"."],
4599
+ ["ื”ืฉืžื™ืจื” ื‘ื•ื“ืงืช ืžื•ืœ Anthropic ืฉื”ืžื•ื“ืœ ืงื™ื™ื ื•ืฉื”ืžืคืชื— ืฉื‘ืฉื™ืžื•ืฉ ื™ื›ื•ืœ ืœื”ืฉืชืžืฉ ื‘ื•. ื”ื™ื ืœื ืขื•ืœื” ื›ืกืฃ."],
4600
+ ["ื”ืžื•ื“ืœ ื”ื—ื“ืฉ ื ื›ื ืก ืœืฉื™ืžื•ืฉ ืžื™ื“, ื’ื ื‘ืฉื™ื—ื•ืช ืคืชื•ื—ื•ืช, ืžื”ืžืฉืคื˜ ื”ื‘ื ืฉืœ ื”ืชืœืžื™ื“."],
4601
+ ["ื”ื‘ืงืฉื” ืžื•ืชืืžืช ืœื›ืœ ืื—ื“ ืžื”ืžื•ื“ืœื™ื ืฉื‘ืจืฉื™ืžื”. ืžื•ื“ืœ ืื—ืจ ืฉืชืงืœื™ื“ื• ืื•ืœื™ ืœื ื™ืงื‘ืœ ืื•ืชื”; ืื ื”ืฉื™ื—ื•ืช ื ื›ืฉืœื•ืช, ื—ื–ืจื• ืœืื—ื“ ืžื”ืจืฉื™ืžื”."]]};
4602
+ // what each key costs, estimated 2026-10-18 for 100 students ร— 5 chats a day ร— 10 turns (the README has how it was worked out)
4603
+ const KEY_COSTS={
4604
+ SAWTAK_API_KEY:{intro:"Sawtak ืžื—ื™ื™ื‘ ืœืคื™ ืชื•ื•ื™ื ืฉืœ ื˜ืงืกื˜ ืžื•ืงืจื: ื‘ื—ืฉื‘ื•ืŸ ื”ื–ื” ื›ึพ$0.05 ืœื›ืœ 1,000 ืชื•ื•ื™ื ($50 ืœืžื™ืœื™ื•ืŸ).",
4605
+ head:["ื”ืฉืžืขืช ื”ืžื•ืจื” (ื‘ืœื™ ืžื˜ืžื•ืŸ)","ืชื•ื•ื™ื","ืขืœื•ืช"],
4606
+ rows:[["ืœืฉื™ื—ื” (11 ื”ื•ื“ืขื•ืช)","~990","~$0.05"],["ืœื™ื•ื (500 ืฉื™ื—ื•ืช)","~495,000","~$25"],["ืœื—ื•ื“ืฉ (15,000 ืฉื™ื—ื•ืช)","~14.9 ืžื™ืœื™ื•ืŸ","~$745"],["ืœืชืœืžื™ื“ ืœื—ื•ื“ืฉ","~149,000","~$7.45"]],
4607
+ notes:["ืื ื’ื ื”ื”ื•ื“ืขื•ืช ืฉืœ ื”ืžื•ืจื” (ื™ืืœืœื” ื ื“ื‘ืจ) ื™ื•ืฉืžืขื• ื‘ึพSawtak (ื”ืงื•ื“ ืขื“ื™ื™ืŸ ืœื ืขื•ืฉื” ืืช ื–ื”), ื›ืœ ื”ื•ื“ืขื” ืขื•ืœื” ืžื—ื“ืฉ: ื”ื˜ืงืกื˜ ืชืžื™ื“ ื—ื“ืฉ, ื•ืœื›ืŸ ืื™ืŸ ืžื˜ืžื•ืŸ. ื”ื•ื“ืขื” ื”ื™ื ืžืฉืคื˜ ืื• ืฉื ื™ื™ื, ื›ึพ90 ืชื•ื•ื™ื ืขื ื ื™ืงื•ื“. ื‘ื”ื•ื“ืขื•ืช ืฉืœ 60 ืขื“ 120 ืชื•ื•ื™ื: ื‘ื™ืŸ ื›ึพ$500 ืœื›ึพ$1,000 ืœื—ื•ื“ืฉ, ื™ื•ืชืจ ืžื”ืžื•ื“ืœ ืขืฆืžื• ื‘ึพHaiku 5.5.",
4608
+ "ื›ืœ ืžืฉืคื˜ ื ื•ืฆืจ ืคืขื ืื—ืช ื•ื ืฉืžืจ ื‘ืžื˜ืžื•ืŸ. ื›ืœ 3,546 ื”ืžืฉืคื˜ื™ื ื”ื ื›ึพ180 ืืœืฃ ืชื•ื•ื™ื, ื›ืœื•ืžืจ ื›ึพ$9 ื‘ืกืš ื”ื›ื•ืœ, ืคืขื ืื—ืช.",
4609
+ "ืžืกืคืจ ื”ืชืœืžื™ื“ื™ื ืœื ืžืฉื ื” ืืช ื”ืขืœื•ืช: 100 ืชืœืžื™ื“ื™ื ืขื•ืœื™ื ื›ืžื• ืชืœืžื™ื“ ืื—ื“, ื›ื™ ืžื”ืฉื ื™ ื•ื”ืœืื” ื”ืžืฉืคื˜ ืžื’ื™ืข ืžื”ืžื˜ืžื•ืŸ.",
4610
+ "ืžื” ื›ืŸ ืขื•ืœื”: ืจื™ืกื˜ืืจื˜ ืฉืœ ื”ึพSpace ื‘ืœื™ ืื—ืกื•ืŸ ืงื‘ื•ืข, ืื• ืžื—ื™ืงืช ืžื˜ืžื•ืŸ ื”ื”ืงืœื˜ื•ืช, ืžื•ื—ืงื™ื ืืช ื”ืžื˜ืžื•ืŸ, ื•ื”ื”ืงืœื˜ื•ืช ื ื•ืฆืจื•ืช ืžื—ื“ืฉ (ืขื“ ื›ึพ$9 ื‘ื›ืœ ืคืขื). ืžื•ืžืœืฅ ืื—ืกื•ืŸ ืงื‘ื•ืข ื•ึพTTS_CACHE_DIR=/data/ihki-tts."]},
4611
+ ANTHROPIC_API_KEY:{intro:"ืœืคื™ 100 ืชืœืžื™ื“ื™ื ร— 5 ืฉื™ื—ื•ืช ื‘ื™ื•ื ร— 10 ืชื•ืจื•ืช: 500 ืฉื™ื—ื•ืช ื‘ื™ื•ื, 15,000 ื‘ื—ื•ื“ืฉ. ื”ืขืœื•ืช ื’ื“ืœื” ๏ฟฝ๏ฟฝื ื”ืฉื™ืขื•ืจื™ื, ื›ื™ ื”ื”ื ื—ื™ื•ืช ืœืžื•ืจื” ื›ื•ืœืœื•ืช ืืช ืื•ืฆืจ ื”ืžื™ืœื™ื ืฉื ืœืžื“ ืขื“ ืื–.",
4612
+ head:["ืžื•ื“ืœ","ืœืฉื™ื—ื”","ืœื™ื•ื","ืœื—ื•ื“ืฉ (ืฉื™ืขื•ืจ 1 / ืืžืฆืข / ืื—ืจื•ืŸ)"],
4613
+ rows:[["Haiku 5.5 (ื‘ืจื™ืจืช ื”ืžื—ื“ืœ)","$0.0075","$3.73","$71 / $112 / $133"],["Haiku 4.5","$0.075","$37","$712 / $1,120 / $1,332"],
4614
+ ["Sonnet 5.5","$0.15","$75","$1,424 / $2,241 / $2,663"],["Opus 5.5 *","$0.26","$130","$2,644 / $3,914 / $4,572"],["Fable 5.1 *","$0.61","$303","$6,354 / $9,077 / $10,486"]],
4615
+ notes:["ื”ืขืžื•ื“ื•ืช ืœืฉื™ื—ื” ื•ืœื™ื•ื ื”ืŸ ืœืืžืฆืข ื”ืงื•ืจืก. ื”ื”ืขืจื›ื” ื”ื™ื ืชืงืจื”: ื›ืœ ืชืœืžื™ื“ ืžื ืฆืœ ืืช 5 ื”ืฉื™ื—ื•ืช, ื›ืœ ื™ื•ื, ืืช ื›ืœ 10 ื”ืชื•ืจื•ืช.",
4616
+ "ื”ืžื—ื™ืจื™ื ื›ื•ืœืœื™ื ืืช ืžื˜ืžื•ืŸ ื”ื”ื ื—ื™ื•ืช (prompt cache). ื‘ืœืขื“ื™ื• ื”ืงืœื˜ ื”ื™ื” ืขื•ืœื” ืคื™ 3 ืขื“ 3.5.",
4617
+ "* ื‘ึพOpus 5.5 ื•ื‘ึพFable 5.1 ืื™ ืืคืฉืจ ืœื›ื‘ื•ืช ืืช ื”ื—ืฉื™ื‘ื”, ื•ืœื›ืŸ ื”ืžื•ืจื” ืžืฉืื™ืจ ืื•ืชื” ื‘ืžืืžืฅ ื ืžื•ืš. ื˜ื•ืงื ื™ ื”ื—ืฉื™ื‘ื” ืžื—ื•ื™ื‘ื™ื ื›ืคืœื˜ ื•ืœื ื ืกืคืจื• ื‘ื˜ื‘ืœื”, ื›ืš ืฉื‘ืคื•ืขืœ ื”ืขืœื•ืช ืฉืœื”ื ืงืฆืช ื’ื‘ื•ื”ื” ื™ื•ืชืจ.",
4618
+ "ืืช ื”ืžื•ื“ืœ ื‘ื•ื—ืจื™ื ื‘ึพTUTOR_MODEL ื‘ืกื•ื“ื•ืช ื”ืฉืจืช."]}};
4619
+ function keyCost(name){
4620
+ const c=KEY_COSTS[name]; if(!c) return null;
4621
+ const box=mark("key-cost",""); box.append(mark("","ื”ืขืจื›ืช ืขืœื•ืช","b"),mark("",c.intro,"p"));
4622
+ if(c.rows){
4623
+ const wrap=mark("key-cost-table",""), table=document.createElement("table"), tr=document.createElement("tr");
4624
+ for(const h of c.head) tr.append(mark("",h,"th")); table.append(tr);
4625
+ for(const r of c.rows){ const row=document.createElement("tr"); r.forEach((v,i)=>{ const td=mark("",v,"td"); if(i) td.dir="ltr"; row.append(td); }); table.append(row); }
4626
+ wrap.append(table); box.append(wrap);
4627
+ }
4628
+ const ul=document.createElement("ul"); for(const n of c.notes) ul.append(mark("",n,"li")); box.append(ul);
4629
+ return box;
4630
+ }
4631
+ function keyGuide(name){
4632
+ const steps=KEY_GUIDES[name]; if(!steps) return null;
4633
+ const details=document.createElement("details"), summary=document.createElement("summary"), list=document.createElement("ol");
4634
+ details.className="key-guide"; summary.textContent="ืื™ืš ืžืฉื™ื’ื™ื ืืช ื”ืžืคืชื—?";
4635
+ for(const step of steps){
4636
+ const li=document.createElement("li");
4637
+ for(const part of step){
4638
+ if(typeof part==="string"){ li.append(part); continue; }
4639
+ const a=document.createElement("a"); a.textContent=part[0]; a.href=part[1]; a.target="_blank"; a.rel="noopener noreferrer"; a.dir="ltr"; li.append(a);
4640
+ }
4641
+ list.append(li);
4642
+ }
4643
+ const cost=keyCost(name);
4644
+ details.append(summary,list,...(cost?[cost]:[]),mark("note","ืืœ ืชืฉืœื—ื• ืžืคืชื— ื‘ืฆืณืื˜, ื‘ืžื™ื™ืœ ืื• ื‘ื•ื•ื˜ืกืืค: ื”ื“ื‘ื™ืงื• ืื•ืชื• ืจืง ื›ืืŸ.","p"));
4645
+ return details;
4646
+ }
4647
+ let API_KEYS=null; // what /api/admin/keys answered: whether each key is set and where from, never the key
4648
+ function drawApiKeys(box){
4649
+ if(!can("keys.manage")) return;
4650
+ const card=mark("card",""), list=document.createElement("div");
4651
+ card.append(mark("","ืžืคืชื—ื•ืช API ื•ืžื•ื“ืœ","h2"),mark("","ืžืคืชื— ื—ื“ืฉ ื ื‘ื“ืง ืžื•ืœ ื”ืกืคืง, ื ืฉืžืจ ืžื•ืฆืคืŸ ื‘ืฉืจืช ื•ื ื›ื ืก ืœืฉื™ืžื•ืฉ ืžื™ื“. ืื—ืจื™ ื”ืฉืžื™ืจื” ื”ื•ื ืœื ืžื•ืฆื’ ืฉื•ื‘, ืจืง ืืจื‘ืขืช ื”ืชื•ื•ื™ื ื”ืื—ืจื•ื ื™ื ืฉืœื•. ื’ื ื”ืžื•ื“ืœ ืฉืœ ื”ืžื•ืจื” ื ืงื‘ืข ื›ืืŸ.","p"),list); box.append(card);
4652
+ const show=data=>{
4653
+ API_KEYS=data; list.innerHTML="";
4654
+ if(data.setup){ const code=mark("",data.setup,"code"); code.dir="ltr"; list.append(mark("note","ื›ื“ื™ ืœืฉืžื•ืจ ืžืคืชื—ื•ืช ืžื”ืคืื ืœ, ื™ืฉ ืœื™ืฆื•ืจ ืืช ื”ื˜ื‘ืœื” app_secrets ื‘ึพSupabase:","p"),code); }
4655
+ else if(!data.writable&&data.why) list.append(mark("note",data.why,"p"));
4656
+ const sources={db:"ื ืฉืžืจ ื‘ืคืื ืœ",env:"ืžื•ื’ื“ืจ ื‘ืฉืจืช",none:"ืœื ืžื•ื’ื“ืจ"};
4657
+ for(const k of data.keys||[]){
4658
+ const row=mark("key-row",""), info=document.createElement("div"), when=k.updated_at?new Date(k.updated_at).toLocaleDateString("he-IL",{timeZone:"Asia/Jerusalem"}):"";
4659
+ const choice=!!k.choices, inUse=choice&&(k.value?`ื‘ืฉื™ืžื•ืฉ: ${k.value}`:"ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ืฉืœ ื”ืงื•ื“ (claude-haiku-5-5)");
4660
+ info.append(mark("",k.label,"b"),mark("",[choice&&k.source==="none"?"":sources[k.source]||k.source,inUse,k.last4&&`ืžืกืชื™ื™ื ื‘ึพ${k.last4}`,k.updated_by&&`ืขืœ ื™ื“ื™ ${k.updated_by}`,when].filter(Boolean).join(" ยท "),"small"));
4661
+ row.append(info); const guide=keyGuide(k.name); if(guide) row.append(guide); list.append(row);
4662
+ if(!data.writable) continue;
4663
+ const form=document.createElement("form"), input=document.createElement("input"), save=mark("btn primary","ืฉืžื™ืจื”","button"), msg=mark("msg","");
4664
+ input.type=choice?"text":"password"; input.autocomplete="off"; input.spellcheck=false; input.dir="ltr"; input.maxLength=300; input.placeholder=choice?"claude-โ€ฆ":"ืžืคืชื— ื—ื“ืฉ"; input.setAttribute("aria-label",choice?"ืžื–ื”ื” ืžื•ื“ืœ ืื—ืจ":`ืžืคืชื— ื—ื“ืฉ: ${k.label}`); save.type="submit";
4665
+ let select=null;
4666
+ if(choice){ // the list, and "other" for an id typed in the field
4667
+ select=document.createElement("select"); select.setAttribute("aria-label",k.label);
4668
+ for(const c of [...k.choices,{id:"",label:"ืื—ืจ (ื”ืงืœื“ื” ื—ื•ืคืฉื™ืช)โ€ฆ"}]){ const o=document.createElement("option"); o.value=c.id; o.textContent=c.id?`${c.label} ยท ${c.id}`:c.label; select.append(o); }
4669
+ const listed=k.choices.some(c=>c.id===(k.value||k.choices[0].id));
4670
+ select.value=listed?(k.value||k.choices[0].id):""; if(!listed) input.value=k.value;
4671
+ const other=()=>{ input.hidden=select.value!==""; if(!input.hidden) input.focus(); };
4672
+ select.onchange=other; input.hidden=select.value!=="";
4673
+ }
4674
+ const send=async(method,body,busy)=>{
4675
+ for(const b of form.querySelectorAll("button")) b.disabled=true; msg.textContent=busy;
4676
+ try{
4677
+ const res=await api(`/api/admin/keys/${k.name}`,{method,...(body?{headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}:{})}), d=await res.json().catch(()=>({}));
4678
+ if(!res.ok) throw new Error(d.detail||res.status);
4679
+ input.value=""; show(d);
4680
+ }catch(err){ msg.textContent="ื ื›ืฉืœ: "+err.message; for(const b of form.querySelectorAll("button")) b.disabled=false; }
4681
+ };
4682
+ form.onsubmit=e=>{ e.preventDefault(); const value=select&&select.value?select.value:input.value.trim(); if(value) send("PUT",{value},choice?"ื‘ื•ื“ืง ืืช ื”ืžื•ื“ืœ ืžื•ืœ Anthropicโ€ฆ":"ื‘ื•ื“ืง ืืช ื”ืžืคืชื— ืžื•ืœ ื”ืกืคืงโ€ฆ"); };
4683
+ if(select) form.append(select); form.append(input,save);
4684
+ if(k.source==="db"){ const back=mark("btn",choice?"ื—ื–ืจื” ืœื‘ืจื™ืจืช ื”ืžื—ื“ืœ ืฉืœ ื”ืฉืจืช":"ื—ื–ืจื” ืœืžืคืชื— ื”ืฉืจืช","button"); back.type="button"; back.onclick=()=>{ if(confirm(choice?"ืœื—ื–ื•ืจ ืœืžื•ื“ืœ ืฉืžื•ื’ื“ืจ ื‘ืฉืจืช (ืื• ืœื‘ืจื™ืจืช ื”ืžื—ื“ืœ)?":"ืœืžื—ื•ืง ืืช ื”ืžืคืชื— ืฉื ืฉืžืจ ื‘ืคืื ืœ ื•ืœื—ื–ื•ืจ ืœืžืคืชื— ืฉืžื•ื’ื“ืจ ื‘ืฉืจืช (ืื ื™ืฉ)?")) send("DELETE",null,"ืžื•ื—ืงโ€ฆ"); }; form.append(back); }
4685
+ form.append(msg); row.append(form);
4686
+ }
4687
+ };
4688
+ if(API_KEYS){ show(API_KEYS); return; }
4689
+ list.append(mark("note","ื˜ื•ืขืŸโ€ฆ","p"));
4690
+ api("/api/admin/keys").then(async res=>{ const d=await res.json().catch(()=>({})); if(!res.ok) throw new Error(d.detail||res.status); show(d); })
4691
+ .catch(err=>{ list.innerHTML=""; list.append(mark("note","ืœื ื ื™ืชืŸ ืœื˜ืขื•ืŸ ืืช ื”ืžืคืชื—ื•ืช: "+err.message,"p")); });
4692
+ }
4693
  function drawActivity(box){
4694
  const days=activityHours===168, range=days?"7 ื”ื™ืžื™ื ื”ืื—ืจื•ื ื™ื":"24 ื”ืฉืขื•ืช ื”ืื—ืจื•ื ื•ืช", bucket=days?"ืฉืขื”":"15 ื“ืงื•ืช";
4695
  const card=mark("card activity",""), head=mark("activity-head",""), title=mark("",`ืคืขื™ืœื•ืช ื‘ึพ${range}`,"h2"), sub=mark("",`ื›ืœ ืขืžื•ื“ื” ืžื™ื™ืฆื’ืช ${bucket} ยท ืžืชืขื“ื›ืŸ ืคืขื ื‘ื“ืงื”`,"p");
tests/test_keystore.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The API keys a developer replaces from the panel (app/keystore.py): kept encrypted, never shown back, put to work at once."""
2
+ import pytest
3
+ from cryptography.fernet import Fernet
4
+ from fastapi.testclient import TestClient
5
+ from test_lab import FakeAuth
6
+ from test_scoring import emissions, scorer_for
7
+
8
+ import app.main
9
+ from app.keystore import KeyStore
10
+ from app.main import create_app
11
+ from app.tts import SawtakTTS
12
+ from app.tutor import Tutor
13
+
14
+ MASTER = Fernet.generate_key().decode()
15
+ NEW = "sk-ant-new-key-0123456789wxyz"
16
+
17
+
18
+ class KeyAuth(FakeAuth):
19
+ secret = "sb_secret_x"
20
+
21
+ def __init__(self, table=True):
22
+ super().__init__()
23
+ self.rows, self.table, self.events = {}, table, []
24
+
25
+ def secret_rows(self):
26
+ return list(self.rows.values()) if self.table else None
27
+
28
+ def save_secret(self, row):
29
+ self.rows[row["name"]] = {**row, "updated_at": "2026-10-18T10:00:00+00:00"}
30
+
31
+ def delete_secret(self, name):
32
+ self.rows.pop(name, None)
33
+
34
+ def log_event(self, kind, user, label="", details=None):
35
+ self.events.append((kind, label, details))
36
+
37
+
38
+ class AdminAuth(KeyAuth):
39
+ developers = frozenset() # t@b.c is a teacher (the panel), not a developer
40
+
41
+
42
+ def bearer(token):
43
+ return {"Authorization": f"Bearer {token}"}
44
+
45
+
46
+ @pytest.fixture
47
+ def keys_env(monkeypatch):
48
+ monkeypatch.setenv("SECRETS_MASTER_KEY", MASTER)
49
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-the-environment-1111")
50
+ monkeypatch.setattr(app.main, "check_key", lambda name, value, anthropic_key="": None)
51
+
52
+
53
+ def client(auth, tmp_path, tutor=None):
54
+ return TestClient(create_app(scorer_for(emissions(["ฤง", "aห", "l"])), SawtakTTS("old-sawtak-key", cache_dir=tmp_path),
55
+ auth=auth, tutor=tutor or Tutor(object())))
56
+
57
+
58
+ def test_a_key_is_kept_encrypted_and_never_shown_back(keys_env, tmp_path):
59
+ auth = KeyAuth()
60
+ with client(auth, tmp_path) as c:
61
+ before = c.get("/api/admin/keys", headers=bearer("teacher")).json()
62
+ assert {k["name"]: k["source"] for k in before["keys"]}["ANTHROPIC_API_KEY"] == "env"
63
+ assert "1111" in repr(before) and "from-the-environment" not in repr(before) # its last four only
64
+ res = c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": f" {NEW} "}, headers=bearer("teacher"))
65
+ assert res.status_code == 200, res.text
66
+ assert NEW not in res.text and res.headers["cache-control"] == "no-store"
67
+ row = next(k for k in res.json()["keys"] if k["name"] == "ANTHROPIC_API_KEY")
68
+ assert (row["source"], row["last4"], row["updated_by"]) == ("db", "wxyz", "T@b.c")
69
+ stored = auth.rows["ANTHROPIC_API_KEY"]
70
+ assert NEW not in stored["ciphertext"]
71
+ assert c.app.state.tutor.client.api_key == NEW # in use without a restart
72
+ assert auth.events and NEW not in repr(auth.events)
73
+ # a restart reads it back from the table
74
+ assert KeyStore(auth, MASTER).get("ANTHROPIC_API_KEY") == "sk-ant-from-the-environment-1111" # not loaded yet
75
+ keys = KeyStore(auth, MASTER)
76
+ keys.load()
77
+ assert keys.get("ANTHROPIC_API_KEY") == NEW
78
+
79
+
80
+ def test_the_sawtak_key_is_swapped_in_place(keys_env, tmp_path):
81
+ with client(KeyAuth(), tmp_path) as c:
82
+ tts = c.app.state.tts
83
+ assert c.put("/api/admin/keys/SAWTAK_API_KEY", json={"value": "sawtak-new-9876"},
84
+ headers=bearer("teacher")).status_code == 200
85
+ assert c.app.state.tts is tts and tts.api_key == "sawtak-new-9876"
86
+
87
+
88
+ def test_clearing_goes_back_to_the_environment_key(keys_env, tmp_path):
89
+ auth = KeyAuth()
90
+ with client(auth, tmp_path) as c:
91
+ c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": NEW}, headers=bearer("teacher"))
92
+ res = c.delete("/api/admin/keys/ANTHROPIC_API_KEY", headers=bearer("teacher"))
93
+ assert res.status_code == 200 and "ANTHROPIC_API_KEY" not in auth.rows
94
+ assert c.app.state.tutor.client.api_key == "sk-ant-from-the-environment-1111"
95
+
96
+
97
+ def test_only_developers_reach_the_keys(keys_env, tmp_path, monkeypatch):
98
+ with client(KeyAuth(), tmp_path) as c: # a student
99
+ assert c.get("/api/admin/keys", headers=bearer("student")).status_code == 403
100
+ assert c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": NEW}, headers=bearer("student")).status_code == 403
101
+ with client(AdminAuth(), tmp_path) as c: # the management panel is not enough
102
+ assert c.get("/api/admin/keys", headers=bearer("teacher")).status_code == 403
103
+ monkeypatch.setattr(app.main, "load_auth", lambda: None) # an open deployment, without sign-in
104
+ with TestClient(create_app(scorer_for(emissions(["ฤง"])), SawtakTTS("k", cache_dir=tmp_path),
105
+ tutor=Tutor(object()))) as c:
106
+ assert c.get("/api/admin/keys").status_code == 403
107
+
108
+
109
+ def test_only_the_two_keys_are_settable(keys_env, tmp_path):
110
+ with client(KeyAuth(), tmp_path) as c:
111
+ for name in ("SUPABASE_SECRET_KEY", "SECRETS_MASTER_KEY"):
112
+ assert c.put(f"/api/admin/keys/{name}", json={"value": NEW}, headers=bearer("teacher")).status_code == 404
113
+ assert c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": "two words"}, headers=bearer("teacher")).status_code == 400
114
+
115
+
116
+ def test_a_refused_key_is_not_kept(keys_env, tmp_path, monkeypatch):
117
+ from app.keystore import KeyRefused
118
+
119
+ def refuse(name, value, anthropic_key=""):
120
+ raise KeyRefused("Anthropic ื“ื—ื” ืืช ื”ืžืคืชื—.")
121
+ monkeypatch.setattr(app.main, "check_key", refuse)
122
+ auth = KeyAuth()
123
+ with client(auth, tmp_path) as c:
124
+ res = c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": NEW}, headers=bearer("teacher"))
125
+ assert res.status_code == 400 and NEW not in res.text and not auth.rows
126
+
127
+
128
+ def test_without_the_master_key_or_the_table_nothing_is_saved(keys_env, tmp_path, monkeypatch):
129
+ with client(KeyAuth(table=False), tmp_path) as c:
130
+ data = c.get("/api/admin/keys", headers=bearer("teacher")).json()
131
+ assert not data["writable"] and "app_secrets" in data["setup"]
132
+ assert c.put("/api/admin/keys/ANTHROPIC_API_KEY", json={"value": NEW}, headers=bearer("teacher")).status_code == 503
133
+ monkeypatch.delenv("SECRETS_MASTER_KEY")
134
+ with client(KeyAuth(), tmp_path) as c:
135
+ data = c.get("/api/admin/keys", headers=bearer("teacher")).json()
136
+ assert not data["writable"] and "SECRETS_MASTER_KEY" in data["why"]
137
+
138
+
139
+ def test_a_row_does_not_decrypt_under_another_name_or_master(tmp_path):
140
+ auth = KeyAuth()
141
+ keys = KeyStore(auth, MASTER)
142
+ keys.set("ANTHROPIC_API_KEY", NEW, "t@b.c")
143
+ auth.rows["SAWTAK_API_KEY"] = {**auth.rows["ANTHROPIC_API_KEY"], "name": "SAWTAK_API_KEY"} # copied across
144
+ again = KeyStore(auth, MASTER)
145
+ again.load()
146
+ assert again.get("ANTHROPIC_API_KEY") == NEW and again.get("SAWTAK_API_KEY") != NEW
147
+ other = KeyStore(auth, Fernet.generate_key().decode())
148
+ other.load()
149
+ assert other.get("ANTHROPIC_API_KEY") != NEW
150
+
151
+
152
+ def test_the_tutor_model_is_shown_and_switched_at_once(keys_env, tmp_path, monkeypatch):
153
+ seen = []
154
+ monkeypatch.setattr(app.main, "check_key", lambda name, value, anthropic_key="": seen.append((name, anthropic_key)))
155
+ monkeypatch.delenv("TUTOR_MODEL", raising=False)
156
+ auth = KeyAuth()
157
+ with client(auth, tmp_path, tutor=Tutor(object(), "claude-haiku-5-5")) as c:
158
+ row = next(k for k in c.get("/api/admin/keys", headers=bearer("teacher")).json()["keys"] if k["name"] == "TUTOR_MODEL")
159
+ assert row["source"] == "none" and row["value"] == "" and row["choices"][0]["id"] == "claude-haiku-5-5"
160
+ res = c.put("/api/admin/keys/TUTOR_MODEL", json={"value": "claude-sonnet-5-5"}, headers=bearer("teacher"))
161
+ assert res.status_code == 200, res.text
162
+ row = next(k for k in res.json()["keys"] if k["name"] == "TUTOR_MODEL")
163
+ assert (row["source"], row["value"], row["last4"]) == ("db", "claude-sonnet-5-5", "") # no secret: shown in full
164
+ assert c.app.state.tutor.model == "claude-sonnet-5-5"
165
+ assert seen == [("TUTOR_MODEL", "sk-ant-from-the-environment-1111")] # checked with the key in use
166
+ c.delete("/api/admin/keys/TUTOR_MODEL", headers=bearer("teacher"))
167
+ assert c.app.state.tutor.model == "claude-haiku-5-5" # back to the code's default
168
+
169
+
170
+ def test_a_model_id_must_look_like_one():
171
+ from app.keystore import KeyRefused, check
172
+ for bad in ("gpt-5", "claude haiku", "claude-"):
173
+ with pytest.raises(KeyRefused):
174
+ check("TUTOR_MODEL", bad, "sk-ant-x")
175
+
176
+
177
+ def test_an_old_table_asks_for_its_migration(keys_env, tmp_path):
178
+ from app.auth import AuthUnavailable
179
+
180
+ class OldTable(KeyAuth):
181
+ def save_secret(self, row):
182
+ raise AuthUnavailable('supabase answered 400: {"code":"23514","message":"violates check constraint"}')
183
+ with client(OldTable(), tmp_path) as c:
184
+ res = c.put("/api/admin/keys/TUTOR_MODEL", json={"value": "claude-sonnet-5-5"}, headers=bearer("teacher"))
185
+ assert res.status_code == 409 and "app_secrets_name_check" in res.json()["detail"]
tests/test_scoring.py CHANGED
@@ -722,6 +722,10 @@ def test_workbook_lesson_2():
722
  assert all("seq" not in p for p in pub if p["cat"] == "l2-poss") # drilled in random order, like the conjugations
723
  by = {(p["cat"], p["he"]): p for p in PHRASES}
724
  assert by["l2-dual", "ืฉื•ืœื—ืŸ, ืฉื ื™ ืฉื•ืœื—ื ื•ืช"]["tr"] == "ื˜ึทืื•ึฐืœึตื”, ื˜ึทืื•ึฐืœึฐืชึตื™ืŸ" # singular and dual said together
 
 
 
 
725
  # ื›ื™ื ื•ื™ื™ ื”ืงื ื™ื™ืŸ: the stem changes before a consonant suffix, and ืฉืœืš is pointed for the prompt voice
726
  assert by["l2-poss", "ื”ืฉื•ืœื—ืŸ ืฉืœื™"]["tr"] == "ื˜ึทืื•ึฐืœึฐืช-ื™" and by["l2-poss", "ื”ืฉื•ืœื—ืŸ ืฉืœื ื•"]["tr"] == "ื˜ึทืื•ึฐืœึตืช-ื ึทื"
727
  you = by["l2-poss", "ื”ื‘ืช ืฉืœืš (ืœืื™ืฉื”)"]
 
722
  assert all("seq" not in p for p in pub if p["cat"] == "l2-poss") # drilled in random order, like the conjugations
723
  by = {(p["cat"], p["he"]): p for p in PHRASES}
724
  assert by["l2-dual", "ืฉื•ืœื—ืŸ, ืฉื ื™ ืฉื•ืœื—ื ื•ืช"]["tr"] == "ื˜ึทืื•ึฐืœึตื”, ื˜ึทืื•ึฐืœึฐืชึตื™ืŸ" # singular and dual said together
725
+ grandpa = by["l2-family", "ืกื‘ื (ืฉืชื™ ืžื™ืœื™ื)"]
726
+ grandma = by["l2-family", "ืกื‘ืชื (ืฉืชื™ ืžื™ืœื™ื)"]
727
+ assert grandpa["variants"] == [("ุณูŠุฏุŒ ุฌุฏู‘", "s iห d | dส’ i d d"), ("ุณูŠุฏ", "s iห d"), ("ุฌุฏู‘", "dส’ i d d")]
728
+ assert grandma["variants"] == [("ุณุชู‘ุŒ ุฌุฏู‘ุฉ", "s i t t | dส’ i d d e"), ("ุณุชู‘", "s i t t"), ("ุฌุฏู‘ุฉ", "dส’ i d d e")]
729
  # ื›ื™ื ื•ื™ื™ ื”ืงื ื™ื™ืŸ: the stem changes before a consonant suffix, and ืฉืœืš is pointed for the prompt voice
730
  assert by["l2-poss", "ื”ืฉื•ืœื—ืŸ ืฉืœื™"]["tr"] == "ื˜ึทืื•ึฐืœึฐืช-ื™" and by["l2-poss", "ื”ืฉื•ืœื—ืŸ ืฉืœื ื•"]["tr"] == "ื˜ึทืื•ึฐืœึตืช-ื ึทื"
731
  you = by["l2-poss", "ื”ื‘ืช ืฉืœืš (ืœืื™ืฉื”)"]
tests/test_tutor.py CHANGED
@@ -193,3 +193,27 @@ def test_a_refused_answer_does_not_count():
193
  model.stop = "end_turn"
194
  assert c.post("/api/tutor/turn", json={"id": chat["id"], "text": "ู…ุฑุญุจุง"}, headers=bearer("teacher")).json()["turn"] == 1
195
  assert c.post("/api/tutor/turn", json={"id": chat["id"], "text": ""}, headers=bearer("teacher")).status_code == 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  model.stop = "end_turn"
194
  assert c.post("/api/tutor/turn", json={"id": chat["id"], "text": "ู…ุฑุญุจุง"}, headers=bearer("teacher")).json()["turn"] == 1
195
  assert c.post("/api/tutor/turn", json={"id": chat["id"], "text": ""}, headers=bearer("teacher")).status_code == 400
196
+
197
+
198
+ @pytest.mark.parametrize("model, thinking, effort", [
199
+ ("claude-haiku-5-5", {"type": "disabled"}, "low"),
200
+ ("claude-sonnet-5-5", {"type": "between_tools"}, "low"),
201
+ ("claude-opus-5-5", None, "low"), # cannot turn thinking off: left adaptive, kept short by effort
202
+ ("claude-fable-5-1", None, "low"),
203
+ ("claude-haiku-4-5", {"type": "disabled"}, None), # no effort parameter
204
+ ])
205
+ def test_each_model_gets_only_what_it_accepts(model, thinking, effort):
206
+ from app.tutor import request_options
207
+ opts = request_options(model)
208
+ assert opts.get("thinking") == thinking and opts["output_config"].get("effort") == effort
209
+ assert opts["output_config"]["format"]["type"] == "json_schema"
210
+ assert opts["max_tokens"] == (8000 if thinking is None else 4000) # room to think before the answer
211
+
212
+
213
+ def test_the_last_turn_note_fits_the_model():
214
+ from app.tutor import last_turn
215
+ history = [{"role": "user", "content": "ืคืชื™ื—ื”"}, {"role": "assistant", "content": "{}"}, {"role": "user", "content": "ู…ุฑุญุจุง"}]
216
+ assert last_turn("claude-haiku-5-5", history, "last")[-1] == {"role": "system", "content": "last"}
217
+ folded = last_turn("claude-haiku-4-5", history, "last") # no system messages there: one user turn, two blocks
218
+ assert len(folded) == 3 and folded[-1]["role"] == "user" and folded[-1]["content"][0]["text"] == "ู…ุฑุญุจุง"
219
+ assert "last" in folded[-1]["content"][1]["text"]