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 +6 -0
- README.md +64 -0
- app/align.py +5 -1
- app/auth.py +18 -0
- app/keystore.py +204 -0
- app/main.py +95 -5
- app/rules.py +7 -1
- app/tts.py +3 -2
- app/tutor.py +52 -14
- app/workbook.py +7 -1
- requirements.txt +1 -0
- static/index.html +151 -3
- tests/test_keystore.py +185 -0
- tests/test_scoring.py +4 -0
- tests/test_tutor.py +24 -0
.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.
|
| 388 |
-
app.state.
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 408 |
-
|
| 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,
|
| 438 |
-
|
| 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
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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=
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]
|