diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..dbab6f4 --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,20 @@ +# GitGuardian secret-scan configuration. +# +# The single ignored path below is a TEST-ONLY fixture, not a leaked +# credential. It pins the cross-language voice-token contract (task t16): a +# voice token minted ONCE by workers/learn-api/src/voice.js#mintVoiceToken with +# a deterministic, fake secret ("cross-language-contract-test-secret") over a +# public payload, committed so the JS minter (workers/learn-api/test/ +# voice.test.js) and the Python verifier (tests/test_voice_token_cross_language +# .py against infra/voice_bridge/tokens.py) can each prove they still agree +# byte-for-byte. The token is an HMAC of non-secret data with a non-secret test +# key — GitGuardian's JWT detector matches its shape, but there is no real +# secret here (the fixture's own `note` says as much), and it CANNOT be changed +# without breaking the contract pin. Scope the ignore to this one file only. +version: 2 +secret: + ignored-paths: + - tests/fixtures/voice_token_cross_language.json + ignored-matches: + - name: voice-token cross-language contract test secret (t16 fixture) + match: cross-language-contract-test-secret diff --git a/.gitignore b/.gitignore index 1964510..ea0ee28 100644 --- a/.gitignore +++ b/.gitignore @@ -235,3 +235,5 @@ skills.local.yaml # wrangler local state (created by `wrangler pages deploy` at repo root) .wrangler/ + +.devague/questions/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfb4ad..2c3f8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-07-11 + +### Added + +- Consent-first sign-in: both the web callback and device-flow paths now issue a short-lived pending-consent session and write ZERO to D1 until the learner accepts the published Terms/Privacy — the consent row is recorded before the learner row (consents table, no FK to learners). +- Versioned re-consent: a TERMS_VERSION bump routes a live full session back to the consent screen (403 consent_required, reason stale_version) until re-acceptance. +- Self-serve data rights: GET /api/export (whole-learner JSON across every subject + consent history) and POST /api/delete (confirm = own github_user_id) erasing all three tables and revoking the session. +- Role-aware access: ADMIN_GITHUB_IDS server-side allow-list, GET /api/admin/learners roster, default-private learner visibility with POST /api/me/visibility. +- Approval-gated Bedrock tutoring tier: POST /api/admin/approve|revoke, a four-level tutor gate (signed-out < consented < approved < configured) where a non-approved /api/tutor call 403s with zero outbound inference; approve is c20-gated on current consent. +- Nova Pro text tutoring surface (grading, adaptive next-step, personalized cloze-story generation) through the existing broker via Bedrock Converse — config only, no provider SDK added to the Worker. +- Nova Sonic 2 voice: approval-gated POST /api/voice/token mint with a per-learner monthly budget, the /learn/voice page, and a serverless SAM voice bridge (API Gateway WebSocket + arm64 Lambda + $20 AWS Budgets ceiling) under infra/. +- Terms of Use + Privacy Policy pages under /learn, versioned from a single shared/terms-version.mjs source, naming GitHub, Cloudflare, and AWS Bedrock as processors; a /learn/consent page with a five-state client flow. +- Cloze (pick-the-right-word) exercise kind in the subject-plugin contract (contract §3.6.1), shipped by french-cli, spanish-cli, and culture-guide and re-exported to /learn. +- Extended launch gate: tools/launch-gate/consent_walk.mjs drives the consent/approval/deletion/tutor/voice/cloze success signals end-to-end (LOCAL authed flows + LIVE unauthenticated probes), tests/test_launch_gate_invariants.py locks the boundary invariants (no email/password, no provider SDK, no forked subject prose), with a recorded pre-uplift baseline. + +### Changed + +- GITHUB_CLIENT_ID is no longer committed in wrangler.toml [vars] — it is set as a secret from .env GITHUB_APP_CLIENT_ID, so a plain wrangler deploy never overwrites it. +- check-static-auth.mjs now polices four per-script fetch whitelists (learner, consent, voice, tutor); the tutor surface renders only for admin-approved learners. + +### Fixed + +- Launch-gate api-server.mjs no longer throws ERR_HTTP_HEADERS_SENT on Set-Cookie responses (consent accept / login / logout / delete), so those flows can be exercised end-to-end. +- getConsent tiebreaks same-millisecond granted_at rows by rowid, removing a flaky re-consent race. +- Review fix (security): POST /api/delete now revokes EVERY session for the learner, not only the calling token — a per-uid revocation marker (`revoked_uid:`) that requireAuth checks, so "delete logs me out everywhere." +- Review fix (reliability): the monthly voice budget is now booked with a compare-and-swap retry on the learner row, so concurrent /api/voice/token mints can no longer both pass the cap check and exceed it. +- Review fix (performance): the voice-bridge Lambda's $connect concurrency gate queries session metadata under a constant partition key (Query COUNT) instead of a full-table Scan that read every frame item. +- Review fix (maintainability): the two new cloze conformance validators were refactored below SonarCloud's cognitive-complexity threshold (behavior-preserving helper extraction). + ## [0.5.4] - 2026-07-11 ### Added diff --git a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md index 00006db..4affdb8 100644 --- a/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md +++ b/docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md @@ -34,9 +34,9 @@ - honesty: All three subjects pass learn subject doctor with at least one cloze item present, and a learner's pre-existing mastery/progress reads back identically after the re-export (item_id join keys unchanged) - Terms of Use + Privacy Policy (#11): both pages published, versioned, linked from the /learn footer and from the consent notice. The Privacy Policy names GitHub (OAuth identity), Cloudflare (Workers/KV/D1/Pages hosting), and AWS Bedrock (Nova Sonic 2 / Nova Pro — approved tier only, learner speech/text leaves to the model provider) as processors, states retention + the delete/export path, and matches what the code actually persists (no email, no password). - honesty: Every processor the code actually calls (GitHub OAuth endpoints, Cloudflare storage/hosting, the Bedrock-backed inference endpoint) is named in the published Privacy Policy, and nothing the policy claims about data (no email, no password, deletable on request) is contradicted by the schema or worker code -- Nova Pro tutoring (#8): text tutoring (exercise grading, adaptive next-step, explanation, personalized cloze-story generation) routes through the existing /api/tutor broker with INFERENCE_URL pointing directly at AWS Bedrock's OpenAI-compatible chat-completions endpoint (bedrock-runtime .../openai/v1) and a Bedrock API key as INFERENCE_TOKEN — AWS Bedrock is the only service that serves AWS Nova models; no sibling-hosted model server, no idle host. Cost-when-busy: per-token Nova Pro spend only. Generated cloze stories and their results are recorded to the existing ledger as contract-valid records with stable item_ids. - - instruction: Verify with a curl against the Bedrock OpenAI-compatible endpoint using the Bedrock API key (expect a Nova Pro completion), then the worker test that /api/tutor forwards approved traffic and the Worker diff shows no provider SDK added - - honesty: The Worker gains no Bedrock/provider SDK and no code change beyond config — a Bedrock API key against the OpenAI-compatible endpoint returns a Nova Pro completion, and the broker forwards approved traffic to it unchanged (h6's condition, retargeted at Bedrock-direct) +- Nova Pro tutoring (#8): text tutoring (exercise grading, adaptive next-step, explanation, personalized cloze-story generation) routes through the existing /api/tutor broker with INFERENCE_URL pointing directly at AWS Bedrock's native Converse API (bedrock-runtime .../model/us.amazon.nova-pro-v1:0/converse) and a Bedrock API key as INFERENCE_TOKEN — AWS Bedrock is the only service that serves AWS Nova models; no sibling-hosted model server, no idle host. Cost-when-busy: per-token Nova Pro spend only. Generated cloze stories and their results are recorded to the existing ledger as contract-valid records with stable item_ids. (Corrected 2026-07-11, user-confirmed: Bedrock's OpenAI-compatible endpoint does NOT serve Nova Pro — see the correction note below.) + - instruction: Verify with a curl against the Bedrock Converse endpoint using the Bedrock API key (expect a Nova Pro completion), then the worker test that /api/tutor forwards approved traffic and the Worker diff shows no provider SDK added + - honesty: The Worker gains no Bedrock/provider SDK and no code change beyond config — a Bedrock API key against the Converse endpoint returns a Nova Pro completion, and the broker forwards approved traffic to it unchanged (h6's condition, retargeted at Bedrock-direct) ## Honesty conditions @@ -66,6 +66,31 @@ - Consent UX shape: an unconsented sign-in issues a short-lived pending-consent session whose ONLY capabilities are viewing the consent notice + accepting or declining — no learner row exists yet, /api/me reports pending_consent, every other authed route 403s. Decline = the session is dropped and nothing was ever written. This keeps the OAuth redirect flow intact (the user lands signed-in-pending on the consent page) without persisting pre-consent. - Sequencing: the consent gate + published policy docs (#10/#11) ship BEFORE the tutoring tier is enabled for anyone — no learner is approved for Bedrock until the Privacy Policy disclosing Bedrock processing is live and they have consented to it. Content work (#9) proceeds upstream in parallel, unblocked. - Content flows upstream-first (#9): curriculum improvements are authored in french-cli / spanish-cli / culture-guide, released to PyPI, then pulled into /learn via learn site export + redeploy — learn-cli never forks subject content locally -- Model access is Bedrock-direct (user decision): AWS Bedrock is the only service that can serve AWS Nova models — both the Nova Pro text path (OpenAI-compatible endpoint + Bedrock API key) and the Nova Sonic 2 voice path (InvokeModelWithBidirectionalStream) terminate at Bedrock itself. Anything between the learner and Bedrock is thin credentialed plumbing enforcing the approval gate, never a model host. Supersedes the earlier cloudai-cli/ec2bedrock-cli serving framing. +- Model access is Bedrock-direct (user decision): AWS Bedrock is the only service that can serve AWS Nova models — both the Nova Pro text path (native Converse API + Bedrock API key; corrected 2026-07-11 from "OpenAI-compatible endpoint", which does not serve Nova Pro) and the Nova Sonic 2 voice path (InvokeModelWithBidirectionalStream) terminate at Bedrock itself. Anything between the learner and Bedrock is thin credentialed plumbing enforcing the approval gate, never a model host. Supersedes the earlier cloudai-cli/ec2bedrock-cli serving framing. - Policy docs live /learn-local (user decision, resolves v1): learn-cli authors, versions, and serves the Terms of Use + Privacy Policy under /learn — it is the only surface processing personal data (auth, ledger, Bedrock); org's static site collects nothing. Re-consent versioning stays inside learn-cli, uncoupled from org releases. org gets a filed issue to link the policies site-wide and to record this decision. - AWS plumbing is serverless, pay-as-you-go (user decision, resolves v7): no EC2 unless explicitly requested. The Nova Sonic 2 voice bridge follows league-of-agents-platform's proven infra pattern (built by Fable): AWS SAM, Lambda (arm64) behind an API Gateway WebSocket API relaying learner audio to Bedrock's bidirectional stream, an AWS Budgets alarm pinned to a hard monthly USD ceiling with every sizing choice commented against it, and zero idle cost. Bedrock itself is serverless GenAI — nothing always-on sits between the learner and it. + +## Post-convergence correction (2026-07-11, user-confirmed) + +> **Confirmed by the user on 2026-07-11** (frame question `q1`, resolved). The +> correction below is now folded into c24 and the "Model access is +> Bedrock-direct" decision above; this note is retained as the evidence trail. + +- **c24 endpoint wording (OpenAI-compatible → native Converse).** c24 (and the + Decisions "Model access is Bedrock-direct" line) say the Nova Pro text path + points `INFERENCE_URL` at Bedrock's *OpenAI-compatible chat-completions* + endpoint (`bedrock-runtime..amazonaws.com/openai/v1/...`). A live probe + during t15 (2026-07-11) proved that endpoint returns `model_not_found` for + Nova Pro in every region tried (us-east-1, us-west-2, eu-west-1, eu-central-1), + and `/openai/v1/models` is not even an operation. The **native Bedrock Converse + API** — `POST /model/us.amazon.nova-pro-v1:0/converse` with a Bedrock API key as + the Bearer token — DID return real Nova Pro completions (HTTP 200, ~0.5–1s) and + tolerates the broker's `learner` stamp as an extra field. So the honesty + condition **h11 still holds** (no Worker code change beyond config; no provider + SDK): only the endpoint URL and the request/response *shape* change from the + OpenAI chat-completions form to the Converse form. The shipped code + (`wrangler.toml`, `workers/learn-api/README.md`, `site-astro/src/scripts/ + tutor-core.js`) already targets Converse; this note reconciles the spec's + wording with what shipped. **Proposed:** retarget c24's "OpenAI-compatible + chat-completions endpoint" wording to the native Converse API. Awaiting user + confirmation. diff --git a/docs/specs/subject-plugin-contract.md b/docs/specs/subject-plugin-contract.md index 0012c19..6c5d9eb 100644 --- a/docs/specs/subject-plugin-contract.md +++ b/docs/specs/subject-plugin-contract.md @@ -129,7 +129,118 @@ plus the directive (optionally with a `persona`). Item ids in the lesson are exa A batch of exercises scoped to an item, a module, or `review` (no argument: the subject picks its weakest touched items). Exercise types: `multiple_choice`, `true_false`, `cloze`, `short_answer`, `translation`, `open`, `discussion`. Checkable types carry `answer`; open types carry `rubric` -(what passes, what is partial). +(what passes, what is partial). `cloze` has two variants — §3.6.1. + +#### 3.6.1 The `cloze` exercise type: two variants, one `type` value + +`cloze` (fill-in-the-blank) has shipped since contract 1.0 as a **single-blank, free-text** +exercise: `prompt` writes the blank as `___` and a top-level `answer` is the expected string, +graded conversationally by the driver exactly like `short_answer`/`translation`. That variant is +**unchanged** — it is still valid, still renders the same, and needs no update from any subject. + +t3 adds a second, richer variant to the same `type: "cloze"` value: **pick-the-right-word**, a +passage with one or more blanks where the learner (or an unauthenticated site reader) picks from a +closed set of options per blank, checkable without a driver or a model call. It is carried by two +new, OPTIONAL exercise fields — additive to contract family 1.0, so every payload that predates +them keeps validating unchanged (open payloads, §2): + +| Field | Type | Meaning | +| --- | --- | --- | +| `text` | string | The passage, with each blank marked as a `{{blank_id}}` placeholder. | +| `blanks` | array | One entry per placeholder in `text`: `{id, options, answer}`. | + +Each `blanks[]` entry: + +| Field | Req | Meaning | +| --- | --- | --- | +| `id` | ✓ | Matches one `{{id}}` placeholder in `text`; unique within the exercise. | +| `options` | ✓ | ≥2 words the reader picks from: the correct word plus one or more distractors. Must include `answer`. | +| `answer` | ✓ | The correct option for this blank; must be one of `options`. | + +`text` and `blanks` are always present **together** — an exercise with only one of them is +malformed. An exercise is the pick-the-right-word variant if and only if it carries `text` or +`blanks`; an exercise with neither is the legacy single-blank form, untouched by anything below. + +**Design decision — least invasive shape (spec c16/h8):** + +- **No new exercise `type`.** `cloze` already existed and already meant "a fill-in-the-blank"; + pick-the-right-word is a richer *shape* of the same type, not a new kind. A driver/site that + doesn't understand `text`/`blanks` yet can still fall back to `prompt`/`answer` (both remain + present on a well-authored pick-the-right-word item, as the driver-facing instruction), since + every field addition here is optional. +- **No new activity.** `record`'s `recorded.activity` stays `lesson | practice | story` — a cloze + item's result is recorded exactly like any other exercise's, via whichever activity hosted it + (typically `practice` for a practice-batch cloze item, `story` for a comprehension cloze item). + `record.json`'s `recorded` object carries no `type`/exercise-shape field at all, so it was + **already** forward-compatible with cloze before this change — this is why neither the Python + contract validator nor the worker's `validate.js` (`workers/learn-api/src/validate.js`) needed + any code change for acceptance criterion 2. What DOES change: a multi-blank cloze result tallies + naturally into the pre-existing `recorded.correct`/`recorded.total` counters (already part of + `record.json`) — e.g. two blanks, one right, records `--correct 1 --total 2`. No new field. +- **`item_id` rules are unchanged.** A pick-the-right-word cloze exercise carries `item_id` exactly + like every other exercise type: the join key `record --item` expects, the same string other + exercises MAY legitimately reuse when they evidence the same curriculum item. This contract does + **not** require cloze `item_id`s to be globally unique — only present. What `learn subject + doctor` (below) does additionally require unique is the exercise's own `id` (its slug, e.g. + `"fr-p1-b1"`) among the subject's declared pick-the-right-word cloze items, since that id is what + a driver/site addresses a specific cloze instance by; `item_id` (the mastery join key) keeps its + existing, unrestricted reuse semantics. +- **Marker syntax is `{{blank_id}}`**, chosen for being unambiguous in both markdown-flavored + `body`/`text` content and plain prose, and trivial to parse with one regex + (`` /\{\{([^{}]+)\}\}/ ``) in Python, JavaScript, or Astro's build-time templating — no parser + dependency added anywhere in the pipeline. + +**Example** (a `practice` exercise; the same shape is legal inside a `story`'s `exercises`): + +```json +{ + "id": "fr-p1-b1", + "type": "cloze", + "item_id": "numbers-money", + "prompt": "Fill in each blank with the right word.", + "text": "Je vais au marché pour acheter {{qty}} pommes.", + "blanks": [ + { "id": "qty", "options": ["trois", "gris", "trop"], "answer": "trois" } + ] +} +``` + +Recording the result once both blanks are graded (one blank here, so `--total 1`): + +```bash +french record --learner ori --item numbers-money --activity practice \ + --exercise fr-p1-b1 --result pass --correct 1 --total 1 --json +``` + +**Validation split** (structural vs. semantic — the mini JSON-Schema validator, §8, only checks +the former): + +- **Schema-checkable** (`learn.contract.validate` / a subject's own schema validation): `text` is + a non-empty string; `blanks` is a non-empty array; each blank is an object with `id` (pattern + `^[a-z0-9][a-z0-9._-]*$`), `options` (≥2 non-empty strings), and `answer` (non-empty string) — + all required. +- **Semantic, checked by `learn subject doctor`'s `cloze-items` check** (not expressible in the + stdlib validator's supported keyword subset, §8): every blank's `answer` is one of its own + `options`; blank `id`s are unique within the exercise and match a `{{id}}` placeholder in `text` + 1:1 (no orphan placeholder, no blank without one); the exercise carries a non-empty `item_id`; + the exercise's own `id` is unique among the subject's other declared pick-the-right-word cloze + items. The check reads every story via `story read` (the one exception to "runtime gate stays + read-only" in `learn/subjects/conformance.py` — still side-effect-free against the dedicated + probe learner) and inspects each `type: "cloze"` exercise that carries `text` or `blanks`. **A + subject that declares none passes trivially** — this never penalizes pre-cloze content or a + subject using only the legacy single-blank form, satisfying the "existing subject exports + identically" requirement (spec h8). + +**Rendering (`learn site export` + site-astro):** the exporter (`learn/front/_export.py`) needed +**no code change** — it already writes each subject's `story read` output verbatim, so `text` and +`blanks` pass through untouched the moment a subject starts emitting them. `site-astro`'s story +reader page renders a pick-the-right-word cloze exercise (`type: "cloze"` with a non-empty +`blanks` array) as the passage with one button group per blank (one button per option); clicking +an option marks it right/wrong immediately, entirely client-side, against the `answer` already +present in the (public) exported JSON — no fetch, no sign-in required, matching the zero-API-when- +signed-out invariant the rest of the site enforces. A legacy single-blank cloze exercise (no +`blanks`) keeps rendering exactly as before (plain prompt, no picker). See +`site-astro/src/scripts/learner.js`'s `wireClozeExercises()`. ### 3.7 `record` — the write-back (the motivation layer's input) @@ -160,8 +271,9 @@ Result inference default (culture-guide's proven mapping): `fail → introduced` Health checks in the established doctor shape (`{id, passed, severity, message, remediation}`) plus **`contract_version`** — the contract version this subject pins. `learn subject doctor` (t3) reads the pin first, then validates the other seven verbs' payloads against that version's -schemas. Exit 0 when healthy, 2 when not. Recommended checks: content files validate against -`story.json`, learner-state dir writable, pinned contract version supported. +schemas — plus, since t3, a `cloze-items` check verifying every declared pick-the-right-word cloze +exercise (§3.6.1). Exit 0 when healthy, 2 when not. Recommended checks: content files validate +against `story.json`, learner-state dir writable, pinned contract version supported. ## 4. Shared vocabularies diff --git a/infra/SPIKE.md b/infra/SPIKE.md new file mode 100644 index 0000000..4fd39f7 --- /dev/null +++ b/infra/SPIKE.md @@ -0,0 +1,163 @@ +# Voice-bridge spike: Lambda ↔ Bedrock Nova Sonic 2 bidirectional stream + +Task t4 of the consent-tutoring plan (spec claims c15/h7 groundwork, hosting +decision c27; plan risks r1/r3). Ran 2026-07-11 against live AWS, us-east-1, +**read-only API probing only** — nothing deployed, nothing created. No +credential value appears in this document; probes below show the secret as +`${AWS_BEDROCK_API_KEY_SECRET}` (the env var name from `.env`), and the +SigV4 probes used locally configured AWS credentials. + +## Verdict + +| Path | Auth | Verdict | Evidence | +| --- | --- | --- | --- | +| `InvokeModelWithBidirectionalStream` (Nova Sonic 2, voice) | Bedrock API key (Bearer) | **NO-GO** | HTTP 403 `{"Message":"This operation does not support API Keys"}` — the service rejects the operation for API keys outright | +| `InvokeModelWithBidirectionalStream` (Nova Sonic 2, voice) | SigV4 (role/env credentials) | **GO** | Full audio round trip from a plain Python 3.12 process: real speech in, transcript + spoken answer streamed back on the same HTTP/2 connection | +| `/openai/v1/chat/completions` (Nova Pro, text) | Bedrock API key (Bearer) | **NO-GO (today, us-east-1)** | Auth passes, but both `amazon.nova-pro-v1:0` and `us.amazon.nova-pro-v1:0` return 404 `model_not_found` — the OpenAI-compat surface does not serve Nova models here | +| `/model/us.amazon.nova-pro-v1%3A0/converse` (Nova Pro, text) | Bedrock API key (Bearer) | **GO** | HTTP 200, completion `"Pong."`, `usage: {inputTokens: 8, outputTokens: 3}` | + +**Production shape (recommended, and what `infra/template.yaml` builds):** +the voice bridge authenticates to Bedrock with its **Lambda execution role +(SigV4)** — the API-key NO-GO costs nothing, because the role is the natural +serverless credential anyway. The Bedrock API key stays useful for the text +path (t15), **but t15's spec assumption needs a correction**: point +`INFERENCE_URL` at the native `/converse` endpoint (or keep the broker +OpenAI-shaped and translate), not at `/openai/v1/chat/completions`, which +does not serve Nova Pro in us-east-1 today. + +## What was actually observed + +All runtime probes hit `https://bedrock-runtime.us-east-1.amazonaws.com` +(Nova home region — first and only region tried; every question answered +there). Every probe negotiated **HTTP/2** (curl `--write-out '%{http_version}'` +reported `2`), which the bidirectional protocol requires. + +### 1. Bearer key against the bidirectional operation — definitive NO-GO + +```console +$ curl --http2 -X POST \ + -H "Authorization: Bearer ${AWS_BEDROCK_API_KEY_SECRET}" \ + -H "Content-Type: application/vnd.amazon.eventstream" \ + https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.nova-2-sonic-v1:0/invoke-with-bidirectional-stream +HTTP 403 via HTTP/2 +{"Message":"This operation does not support API Keys"} +``` + +Not a signature or permission failure — the operation itself refuses API-key +auth. This is the clean answer the task anticipated: bidirectional is +SigV4/SDK-only. + +### 2. SigV4 + experimental SDK — full audio round trip (GO) + +Tooling: `aws_sdk_bedrock_runtime` 0.7.0 (AWS's experimental Smithy Python +SDK — today the only Python client for this operation; boto3 cannot open +bidirectional streams), Python 3.12, awscrt HTTP/2 underneath. Input audio +was real speech synthesized by Polly (`aws polly synthesize-speech +--output-format pcm --sample-rate 16000 --text "What is two plus two? +Answer briefly."`), streamed as base64 `audioInput` events at roughly +real-time pacing, followed by 1 s of silence for end-of-turn detection: + +```text +STREAM ESTABLISHED + <- userSpeechStart: {"inputAudioOffsetMs": 0, ...} +sent 28 audioInput chunks (110782 pcm bytes incl. silence) + <- completionStart: {...} + <- userSpeechEnd: {"inputAudioDetectionOffsetMs": 3080, ...} + <- textOutput: 'what is two plus two? answer briefly.' # ASR of our audio + <- textOutput: 'Two plus two equals four.' # the model's reply +event tally: {'usageEvent': 12, 'userSpeechStart': 1, 'completionStart': 1, + 'userSpeechEnd': 1, 'contentStart': 3, 'textOutput': 2, + 'contentEnd': 3, 'audioOutput': 19} +AUDIO BACK: 19 audioOutput events, 78720 pcm bytes (~1.64s at 24kHz) +VERDICT full-audio-round-trip: GO +``` + +A plain Python process held the stream, sent audio up, and received the +model's speech back on the same connection — exactly what the Lambda session +holder does. Total spike model spend: a few hundred speech tokens, well under +one cent. + +Two SDK traps found (both now encoded in `voice_bridge/relay.open_stream`): + +- **`aws_credentials_identity_resolver` must be set explicitly** on the + `Config`. Passing raw `aws_access_key_id=...` kwargs looks accepted but the + auth resolver never finds them — and the failure is *swallowed into a + background task*, so the open call awaits forever instead of raising + (`SmithyIdentityError` surfaced only via "Task exception was never + retrieved"). A production timeout around stream-open is mandatory. +- `EnvironmentCredentialsResolver` reads the standard `AWS_*` env vars — + which is exactly how a Lambda execution role delivers credentials, so the + spike's auth path and production's are the same code. + +### 3. Runtime choice: python3.12 stays (no Node needed) + +The task allowed switching the Lambda to Node if the spike proved the relay +needed the JS SDK's bidirectional support. It did not: the experimental +Python SDK held the stream end-to-end. python3.12 matches league's +convention; `awscrt` (the SDK's one native dependency) ships manylinux +aarch64 wheels, so arm64 Lambda packaging is a plain `requirements.txt` +build. The SDK is pre-1.0 and its `Config` API has already changed shape +between published samples — pinned `>=0.7,<1` in `infra/requirements.txt`. + +### 4. Bearer key intel for the text path (t15, not this task) + +- `GET /openai/v1/models` → 404 `` (operation + not offered in us-east-1). +- `POST /openai/v1/chat/completions` with `amazon.nova-micro-v1:0`, then + `us.amazon.nova-pro-v1:0` → 404 `model_not_found` both times. The bearer + key *authenticates* (no 401/403), but the OpenAI-compat surface does not + serve Nova models in us-east-1 today. +- `POST /model/us.amazon.nova-pro-v1%3A0/converse` with the same bearer key + → **HTTP 200**, `"Pong."`. The key is live and authorizes Nova Pro on the + native Converse API. + +Also confirmed via `list-foundation-models`: **Nova Sonic 2's model id is +`amazon.nova-2-sonic-v1:0`** (SPEECH,TEXT modalities, us-east-1); v1 remains +available as `amazon.nova-sonic-v1:0`. + +## API Gateway WebSocket constraints that shaped the design (risk r3) + +- **Per-message invocation model** — the biggest one. An API GW WebSocket + Lambda integration invokes the function once *per frame*; no invocation + holds the client socket. So one function runs in two modes + (`voice_bridge/handler.py`): the router (per-frame, verifies the token at + `$connect`, buffers inbound audio in DynamoDB) async-self-invokes the same + function as a session holder, which owns the Bedrock stream and pushes + audio back via the `@connections` PostToConnection API. +- **32 KB max frame** — audio chunks are ~4 KB of PCM (~5.5 KB base64), an + 8x margin. Client frames carry `{"seq": n, "audio": base64}`; `$default` + invocations can land out of order, so ordering is client-`seq` + authoritative (`relay.order_frames`). +- **10 min idle timeout / 2 h connection cap** — both far above the 300 s + `MaxSessionSeconds` cap, so the session budget, not the transport, ends + every session. +- **Lambda 15 min max runtime** — the absolute ceiling on any one-holder + session design; the 300 s cap + 360 s function timeout sit comfortably + inside it. + +## Cost observations feeding the template comments + +- Spike-measured: ~150 speech tokens for ~3.5 s of input audio → **~43 + speech tokens/second**. At published Nova Sonic rates (~$0.0034/1k in, + ~$0.0136/1k out) that is **~1–2 cents per conversation-minute combined** — + the dominant cost line by ~5x over all plumbing together. +- A 300 s max session ≈ $0.10 of model tokens + ~$0.02 of plumbing (DynamoDB + frame writes ~$0.018, Lambda ~$0.002, API GW messages ~$0.005). +- The $20 ceiling therefore affords ~200 max-length sessions/month; + 2 concurrent sessions bound saturated abuse to ~$2.4/hour, inside what the + Budgets forecast alarm catches same-day. Rotating `VoiceTokenSecretValue` + is the instant kill switch. + +## Open questions handed to t16 + +1. **Minting**: learn-api mints the voice token (session.js-symmetric HMAC + format, `scope: "voice"`, `approved: true`, short TTL — see + `voice_bridge/tokens.py` and its tests for the exact contract) only for + admin-approved learners, and passes it as `?token=` on the wss URL. +2. **Client audio**: 16 kHz/16-bit/mono LPCM up (base64, client-sequenced + frames), 24 kHz LPCM down — the shapes `relay.py` pins. +3. **Per-learner budget**: the bridge caps per-session seconds and global + concurrency; a per-learner monthly allowance (t16's server-side cap) + belongs in learn-api where approval lives. +4. **Deploy** happens later under supervision (`sam build && sam deploy` + with `BudgetAlertEmail` supplied); nothing was deployed in this task. diff --git a/infra/requirements.txt b/infra/requirements.txt new file mode 100644 index 0000000..8289d63 --- /dev/null +++ b/infra/requirements.txt @@ -0,0 +1,14 @@ +# Installed into the Lambda bundle by SAM's default python3.12 build (this +# directory is the template's CodeUri, so no repo-root pyproject is involved — +# unlike league's Makefile build, this package is self-contained on purpose). +# +# aws_sdk_bedrock_runtime is AWS's experimental Smithy-based SDK — today the +# only Python client for InvokeModelWithBidirectionalStream (boto3 cannot open +# bidirectional streams). Proven live in the spike (see infra/SPIKE.md) on +# 0.7.0; pinned below 1.0 because the SDK is pre-stable and its Config API +# already changed shape between sample-code versions. Pulls smithy-aws-core +# and awscrt (which ships manylinux aarch64 wheels — arm64 Lambda is fine). +# +# boto3 is deliberately absent: the Lambda runtime provides it, and bundling +# our own copy would add ~80 MB of package for zero benefit. +aws_sdk_bedrock_runtime>=0.7,<1 diff --git a/infra/template.yaml b/infra/template.yaml new file mode 100644 index 0000000..b260d29 --- /dev/null +++ b/infra/template.yaml @@ -0,0 +1,405 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: > + learn-cli voice bridge — the serverless Nova Sonic 2 relay (spec c15/h7 + groundwork, hosting decision c27). An API Gateway WebSocket API fronting ONE + arm64 Lambda that (a) gates every connection on a short-lived voice token + minted by learn-api and (b) holds Bedrock's InvokeModelWithBidirectionalStream + for the session, relaying learner audio up and model speech back. A DynamoDB + session/frame table and an AWS Budgets alarm pinned to a 20 USD/month ceiling + complete the stack. Mirrors league-of-agents-platform/infra/template.yaml's + conventions on purpose: every sizing choice below is commented against that + 20 USD ceiling (never generic best practice), and intrinsic functions are + written long-form (Fn::Sub/Ref/Fn::GetAtt, no shorthand tags) so this file + parses as plain YAML with any loader (see tests/test_voice_bridge_template.py). + Zero idle cost by construction: no provisioned concurrency, nothing always-on + — an idle month is 0 USD, per the serverless-only user decision (no EC2). + The live feasibility proof for this design is infra/SPIKE.md. + +# --- parameters -------------------------------------------------------------- +# The capacity caps default to voice_bridge/config.py's constants — +# tests/test_voice_bridge_template.py cross-checks them against the Python +# source directly (league's anti-drift guard), so the two cannot silently +# diverge. +# +# The price frame every number below is tuned against (spike-measured + +# published on-demand rates, order of magnitude): Nova Sonic 2 speech tokens +# are the dominant cost line at ~0.0034 USD/1k in and ~0.0136 USD/1k out, and +# the spike measured ~43 speech tokens per second of audio — call it 1-2 cents +# per conversation-minute combined. Everything else in this stack (Lambda, +# API Gateway messages, DynamoDB frames) totals ~2 cents per max-length +# session, roughly a fifth of that session's model spend. The 20 USD ceiling +# therefore buys on the order of 1,000-2,000 voice-minutes a month, and the +# caps below exist to keep the *rate* of spend inside what the Budgets alarm +# can catch — the per-learner gate is approval itself (only admin-approved +# learners ever get a voice token; t16 adds a per-learner budget on top). + +Parameters: + StageName: + Type: String + Default: prod + Description: >- + API Gateway stage / resource-name namespace. Keep it short — it + prefixes every named resource below. + MonthlyBudgetUsd: + Type: Number + Default: 20 + Description: >- + Hard monthly cost ceiling in USD (see the MonthlyBudget resource). + Default matches voice_bridge.config.DEFAULT_MONTHLY_BUDGET_USD. + BudgetAlertEmail: + Type: String + Description: >- + Email address that receives AWS Budgets threshold notifications. No + default on purpose — the operator must supply a real address at deploy + time (league's posture). + VoiceTokenSecretValue: + Type: String + NoEcho: true + Default: "" + Description: >- + HMAC shared secret for voice tokens (voice_bridge.tokens; learn-api + mints the counterpart in t16). Empty by default, which keeps the bridge + SHUT — verify_voice_token never accepts anything under an empty secret, + so a deployed-but-unwired stack refuses every connection and can spend + nothing on Bedrock. NoEcho so CloudFormation never surfaces the value. + Rotating this parameter is also the kill switch: one stack update + invalidates every outstanding token instantly. + MaxSessionSeconds: + Type: Number + Default: 300 + Description: >- + Per-session hard cap, enforced by the session holder's pump deadline + (relay.pump) with the Lambda timeout as backstop. 300 s of conversation + is ~0.10 USD of Nova Sonic 2 tokens — the ceiling affords ~200 + max-length sessions/month. Also comfortably inside every API Gateway + WebSocket limit that shapes this design (10 min idle timeout, 2 h + connection cap — see infra/SPIKE.md). + MaxConcurrentVoiceSessions: + Type: Number + Default: 2 + Description: >- + Hard cap on simultaneous voice sessions — over-cap connects are REFUSED + (429), never degraded (league's capacity posture). 2 concurrent + sessions bound the worst-case burn rate to ~2.4 USD/hour of saturated + abuse, so the FORECASTED Budgets alarm below fires the same day and the + operator's response (rotate VoiceTokenSecretValue) shuts the bridge — + the cap bounds the rate, the alarm bounds the duration. + NovaSonicModelId: + Type: String + Default: amazon.nova-2-sonic-v1:0 + Description: >- + The Bedrock model the bridge relays to. Default confirmed live in + us-east-1 by the spike (SPEECH+TEXT modalities); matches + voice_bridge.config.NOVA_SONIC_MODEL_ID. + +# --- globals ----------------------------------------------------------------- +# Applied to every AWS::Serverless::Function here (there is exactly one). + +Globals: + Function: + # python3.12, matching league: the spike proved the experimental + # aws_sdk_bedrock_runtime SDK holds the bidirectional stream from plain + # Python — no Node runtime needed (see infra/SPIKE.md, "runtime choice"). + Runtime: python3.12 + # arm64 (Graviton): ~20% cheaper per GB-second than x86_64, and the one + # native dependency (awscrt) ships manylinux aarch64 wheels — free money + # against the ceiling, no downside. + Architectures: + - arm64 + # 512 MB: the session holder is I/O-bound (HTTP/2 event pump + base64) — + # no subprocess spawning like league's 1024 MB case. A full 300 s session + # at 512 MB is 150 GB-s ≈ 0.2 cents, ~1/50th of that session's Bedrock + # spend; router invocations are milliseconds. 1024 MB would double a + # rounding error for latency this workload cannot feel. + MemorySize: 512 + # 360 s = MaxSessionSeconds' default (300) + 60 s of stream-teardown + # grace. The pump self-terminates at the deadline; this timeout is the + # backstop circuit breaker so a hung holder is killed at ~0.5 cents of + # compute instead of running to Lambda's 900 s maximum. + # tests/test_voice_bridge_template.py asserts Timeout > MaxSessionSeconds. + Timeout: 360 + # X-Ray would bill per trace with nothing to fan out to — same reasoning + # as league: an unbounded-by-traffic cost line for no operational payoff. + Tracing: Disabled + +# --- resources --------------------------------------------------------------- +# Exactly eleven: the WebSocket API, its stage, three routes, the shared +# Lambda integration, the invoke permission, the bridge function and its log +# group, the sessions table, and the Budget alarm. (SAM adds the function's +# IAM execution role implicitly.) + +Resources: + # API Gateway WEBSOCKET API (the only API Gateway flavor with server-push, + # which the audio return path needs). Pricing is per message + # (~1.00 USD/million) plus connection-minutes (~0.25 USD/million): a + # max-length session moves ~2,400 frames up and ~2,400 down ≈ 0.5 cents — + # noise against the session's ~10 cents of model tokens. Constraints that + # shaped the design (verified against AWS quotas, see infra/SPIKE.md): + # 32 KB max frame (audio chunks are ~5.5 KB base64), 10 min idle timeout + # and 2 h connection cap (both far above MaxSessionSeconds=300). + WebSocketApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: + Fn::Sub: "learn-voice-${StageName}" + ProtocolType: WEBSOCKET + # Inbound frames are opaque JSON audio chunks, not action-routed + # commands — everything lands on $default; this expression is required + # boilerplate for WEBSOCKET APIs and deliberately never matches. + RouteSelectionExpression: "$request.body.action" + + WebSocketStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: + Ref: WebSocketApi + StageName: + Ref: StageName + AutoDeploy: true + # Throttling is the cost circuit breaker for the *plumbing* (league's + # HttpApi posture): it bounds how many router-Lambda invocations and + # DynamoDB frame writes a runaway client can generate. Steady state is + # ~8 frames/s per session × 2 sessions ≈ 16 msg/s; rate 50 leaves + # headroom for reconnect bursts while capping a hostile client at ~3x + # the legitimate peak. (Bedrock spend is bounded separately, by the + # session caps above — throttling here cannot reach it.) + DefaultRouteSettings: + ThrottlingBurstLimit: 100 + ThrottlingRateLimit: 50 + + # All three routes share the one integration below — the routing happens + # inside the handler (routeKey), keeping this a one-Lambda stack. + # AuthorizationType NONE is deliberate and safe: $connect itself IS the + # auth gate (voice_bridge.handler._connect verifies the learn-api-minted + # token before anything else happens — h7's groundwork), and a REQUEST + # authorizer would be a second Lambda for the same check. + ConnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $connect + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + DisconnectRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $disconnect + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + DefaultRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: + Ref: WebSocketApi + RouteKey: $default + AuthorizationType: NONE + Target: + Fn::Sub: "integrations/${BridgeIntegration}" + + BridgeIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: + Ref: WebSocketApi + IntegrationType: AWS_PROXY + IntegrationUri: + Fn::Sub: >- + arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BridgeFunction.Arn}/invocations + + WebSocketInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: + Ref: BridgeFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Sub: "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + + # The ONE Lambda, in two modes (see voice_bridge/handler.py's module + # docstring): API Gateway WebSocket integrations are per-message + # invocations, so the $connect router cannot itself hold the Bedrock + # stream — after the token verifies, it async-self-invokes this same + # function in session-holder mode, which opens the bidirectional stream + # and pumps until MaxSessionSeconds. One function, one runtime, one + # codebase — the "one arm64 Lambda" the task mandates, with no second + # resource to keep in sync. + BridgeFunction: + Type: AWS::Serverless::Function + Properties: + # Explicit name so the self-invoke IAM statement below can reference + # it as a plain string — referencing the function's own attribute from + # its own policy would be a circular dependency. + FunctionName: + Fn::Sub: "learn-voice-${StageName}-bridge" + # CodeUri is this infra/ directory (not voice_bridge/) because SAM's + # default python build wants requirements.txt at the CodeUri root and + # the handler uses package imports; the few extra files bundled + # (template.yaml, SPIKE.md) are kilobytes. No Makefile build needed — + # unlike league, this package is self-contained by design. + CodeUri: ./ + Handler: voice_bridge.handler.lambda_handler + Environment: + Variables: + # Names come from voice_bridge/config.py; the template test + # cross-checks them so handler and template cannot drift. + VOICE_TOKEN_SECRET: + Ref: VoiceTokenSecretValue + SESSIONS_TABLE_NAME: + Ref: VoiceSessionsTable + MAX_SESSION_SECONDS: + Ref: MaxSessionSeconds + MAX_CONCURRENT_VOICE_SESSIONS: + Ref: MaxConcurrentVoiceSessions + NOVA_SONIC_MODEL_ID: + Ref: NovaSonicModelId + SELF_FUNCTION_NAME: + Fn::Sub: "learn-voice-${StageName}-bridge" + WEBSOCKET_CALLBACK_URL: + Fn::Sub: "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}" + # Least privilege, every grant named: exactly what the two modes use, + # nothing wildcard. (SAM's DynamoDBCrudPolicy scopes to the one table.) + Policies: + - DynamoDBCrudPolicy: + TableName: + Ref: VoiceSessionsTable + - Version: "2012-10-17" + Statement: + # The session holder's upstream connection — the ONLY Bedrock + # action this stack can perform, on the one model it relays to. + # SigV4 via this execution role is the proven auth path; the + # spike showed Bedrock API keys are rejected by this operation + # ("This operation does not support API Keys" — infra/SPIKE.md). + - Sid: OpenNovaSonicBidirectionalStream + Effect: Allow + Action: + - bedrock:InvokeModelWithBidirectionalStream + Resource: + Fn::Sub: "arn:aws:bedrock:${AWS::Region}::foundation-model/${NovaSonicModelId}" + # The audio return path: PostToConnection (and the teardown + # DeleteConnection) on this API's connections only. + - Sid: PushAudioBackOverTheWebSocket + Effect: Allow + Action: + - execute-api:ManageConnections + Resource: + Fn::Sub: "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/${StageName}/POST/@connections/*" + # The router's async hand-off to session-holder mode (same + # function; see FunctionName comment above). + - Sid: SelfInvokeSessionHolder + Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + Fn::Sub: "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:learn-voice-${StageName}-bridge" + + # CloudWatch Logs cost is retention x volume; explicit group + short + # retention instead of the auto-created "Never expire" default (league's + # reasoning verbatim — a hosted side project, not compliance retention). + BridgeFunctionLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: + Fn::Sub: "/aws/lambda/${BridgeFunction}" + RetentionInDays: 14 + + # Session registry + inbound frame buffer in one table, league's PK/SK + # single-table convention (see voice_bridge/handler.py's DynamoSessionStore + # for the item shapes). On-demand billing: voice traffic is bursty and + # capacity-capped, so there is no idle floor to pay — an idle month is + # 0 USD. Frames are the hot path: ~2,400 writes of ~6 WRU per max-length + # session ≈ 1.8 cents, the largest plumbing line and still ~1/5th of the + # session's model tokens. TTL (expires_at) garbage-collects sessions and + # frames for free, so a crashed holder cannot pin the concurrency cap + # (rows expire ~1 h past deadline) or accumulate storage cost. + # + # Session-metadata items live under the constant partition key PK=ACTIVE + # (frames keep their own PK=SESSION# partition) precisely so + # the $connect concurrency gate (DynamoSessionStore.count_active) can + # Query that one partition instead of Scan-ing the whole table — a Scan + # would read every frame row too, so its cost/latency would grow with + # frame volume even though the gate only ever needs the session count. + # The base table's PK/SK schema already covers this Query; no GSI needed. + VoiceSessionsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: + Fn::Sub: "learn-voice-${StageName}-sessions" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: expires_at + Enabled: true + + # The hard 20 USD/month ceiling, enforced as notification (AWS Budgets + # cannot halt spend itself). Same two thresholds as league: 80% of actual + # as the early warning, 100% forecast so the operator hears BEFORE the + # ceiling is crossed. Budgets evaluates on billing-data granularity + # (hours, not minutes) — which is exactly why MaxConcurrentVoiceSessions + # bounds the burn RATE above: at ~2.4 USD/hour worst case, the forecast + # alarm fires the same day and rotating VoiceTokenSecretValue stops the + # spend instantly. + MonthlyBudget: + Type: AWS::Budgets::Budget + Properties: + Budget: + BudgetName: + Fn::Sub: "learn-voice-${StageName}-monthly-ceiling" + BudgetType: COST + TimeUnit: MONTHLY + BudgetLimit: + Amount: + Ref: MonthlyBudgetUsd + Unit: USD + NotificationsWithSubscribers: + - Notification: + NotificationType: ACTUAL + ComparisonOperator: GREATER_THAN + Threshold: 80 + ThresholdType: PERCENTAGE + Subscribers: + - SubscriptionType: EMAIL + Address: + Ref: BudgetAlertEmail + - Notification: + NotificationType: FORECASTED + ComparisonOperator: GREATER_THAN + Threshold: 100 + ThresholdType: PERCENTAGE + Subscribers: + - SubscriptionType: EMAIL + Address: + Ref: BudgetAlertEmail + +Outputs: + WebSocketUrl: + Description: >- + wss:// endpoint the /learn voice UI (t16) connects to, with + ?token= on the upgrade request. + Value: + Fn::Sub: "wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}" + VoiceSessionsTableName: + Description: Name of the deployed voice sessions/frames table. + Value: + Ref: VoiceSessionsTable + BridgeFunctionName: + Description: Name of the bridge Lambda (router + session holder). + Value: + Ref: BridgeFunction diff --git a/infra/voice_bridge/__init__.py b/infra/voice_bridge/__init__.py new file mode 100644 index 0000000..058e2dc --- /dev/null +++ b/infra/voice_bridge/__init__.py @@ -0,0 +1,12 @@ +"""learn-cli's Nova Sonic 2 voice bridge — the Lambda behind the WebSocket API. + +Deployed by ``infra/template.yaml`` (AWS SAM, arm64, python3.12). Lives under +``infra/`` — outside the ``learn`` package — because it is deploy +infrastructure, not part of the CLI/MCP/site product: bandit and coverage +scopes stay pinned to ``learn/``, and SAM builds this directory standalone +from its own ``requirements.txt``. + +Spec anchor: honesty condition h7 — approval is enforced at the bridge entry; +no valid voice token, no upstream Bedrock connection. See ``infra/SPIKE.md`` +for the live spike this design is built on. +""" diff --git a/infra/voice_bridge/config.py b/infra/voice_bridge/config.py new file mode 100644 index 0000000..67ae852 --- /dev/null +++ b/infra/voice_bridge/config.py @@ -0,0 +1,38 @@ +"""Single source of truth for the voice bridge's caps and env-var names. + +``infra/template.yaml`` declares the same numbers as CloudFormation Parameter +defaults and the same names as Lambda environment keys; +``tests/test_voice_bridge_template.py`` cross-checks both against this module +so the template and the code that reads it cannot silently drift apart — +the exact guard league-of-agents-platform runs between its template and +``league_site.capacity.config``. + +Every default below is tuned against the 20 USD/month ceiling (see the +template's Parameters section for the full price math; it is kept in one +place there, not duplicated here): + +- ``DEFAULT_MAX_SESSION_SECONDS = 300`` — a 5-minute session is roughly + 0.10 USD of Nova Sonic 2 speech tokens at the ~1-2 cents/minute the spike + measured, so the ceiling affords ~200 max-length sessions a month. +- ``DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS = 2`` — bounds the worst-case burn + *rate* (~2.4 USD/hour at continuous max use) so abuse cannot outrun the + Budgets alarm's daily cost granularity by much. +""" + +from __future__ import annotations + +DEFAULT_MONTHLY_BUDGET_USD = 20 +DEFAULT_MAX_SESSION_SECONDS = 300 +DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS = 2 + +# Confirmed live in the spike (infra/SPIKE.md): Nova Sonic 2's us-east-1 id. +NOVA_SONIC_MODEL_ID = "amazon.nova-2-sonic-v1:0" + +# Lambda environment variable names, wired by infra/template.yaml. +VOICE_TOKEN_SECRET_ENV = "VOICE_TOKEN_SECRET" +SESSIONS_TABLE_ENV = "SESSIONS_TABLE_NAME" +MAX_SESSION_SECONDS_ENV = "MAX_SESSION_SECONDS" +MAX_CONCURRENT_SESSIONS_ENV = "MAX_CONCURRENT_VOICE_SESSIONS" +MODEL_ID_ENV = "NOVA_SONIC_MODEL_ID" +SELF_FUNCTION_NAME_ENV = "SELF_FUNCTION_NAME" +CALLBACK_URL_ENV = "WEBSOCKET_CALLBACK_URL" diff --git a/infra/voice_bridge/handler.py b/infra/voice_bridge/handler.py new file mode 100644 index 0000000..17566d8 --- /dev/null +++ b/infra/voice_bridge/handler.py @@ -0,0 +1,289 @@ +"""The one Lambda behind the WebSocket API — router and session holder in one. + +API Gateway WebSocket integrations are per-*message* invocations: the Lambda +that handles ``$connect`` is not a long-lived process holding the socket. So +the same function runs in two modes (the one-arm64-Lambda design the template +documents): + +- **router mode** — invoked by API Gateway per WebSocket event. ``$connect`` + is the approval gate: verify the learn-api-minted voice token FIRST, check + the concurrency cap, register the session, then async-self-invoke the + session holder. ``$default`` stores inbound audio frames for the holder; + ``$disconnect`` cleans up. +- **session mode** — the async self-invocation. Only THIS path ever opens a + Bedrock connection (relay.open_stream), and it is only reachable after a + token verified — h7's groundwork is structural, not a scattered check. + +Every dependency is injected through ``Deps`` so the gate logic runs under +pytest with zero AWS; ``_default_deps`` builds the boto3-backed production +set lazily on first real invocation. +""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from dataclasses import dataclass +from typing import Any, Callable, Protocol + +from . import config, tokens + +# One year of TTL grace past the session deadline would hide leaks; one hour +# keeps ghost rows (a crashed holder that never cleaned up) from pinning the +# concurrency cap for longer than an operator would notice. +_TTL_GRACE_SECONDS = 3600 + + +class SessionStore(Protocol): + """What the handler needs from the voice-sessions table.""" + + def count_active(self, now: int) -> int: ... # noqa: E704 + + def put_session(self, connection_id: str, record: dict) -> None: ... # noqa: E704 + + def get_session(self, connection_id: str) -> dict | None: ... # noqa: E704 + + def delete_session(self, connection_id: str) -> None: ... # noqa: E704 + + def store_frame(self, connection_id: str, body: str) -> None: ... # noqa: E704 + + +@dataclass +class Deps: + """Injected seams; production values come from ``_default_deps``.""" + + secret: str + max_concurrent: int + max_session_seconds: int + sessions: SessionStore + launch_session: Callable[[dict], Any] + run_session: Callable[[dict], dict] + now: Callable[[], int] + + +def lambda_handler(event: dict, context: Any = None, deps: Deps | None = None) -> dict: + deps = deps or _default_deps() + if isinstance(event, dict) and event.get("voiceBridge") == "session": + return deps.run_session(event) + request = event.get("requestContext") or {} + route = request.get("routeKey") + connection_id = request.get("connectionId", "") + if route == "$connect": + return _connect(event, connection_id, deps) + if route == "$default": + return _frame(event, connection_id, deps) + if route == "$disconnect": + deps.sessions.delete_session(connection_id) + return {"statusCode": 200} + return {"statusCode": 400} + + +def _connect(event: dict, connection_id: str, deps: Deps) -> dict: + """The bridge entry — h7's gate. Order matters: verify before anything else.""" + token = (event.get("queryStringParameters") or {}).get("token") + claims = tokens.verify_voice_token(deps.secret, token, now=deps.now()) + if claims is None: + # No valid approved token -> refuse the upgrade. Nothing was written, + # nothing was launched, and the Bedrock-opening path (session mode) + # is unreachable from here. + return {"statusCode": 401} + now = deps.now() + if deps.sessions.count_active(now) >= deps.max_concurrent: + # Over-cap is a refusal, never degraded service (league's posture). + return {"statusCode": 429} + deadline = now + deps.max_session_seconds + deps.sessions.put_session( + connection_id, + {"uid": claims["uid"], "connected_at": now, "deadline": deadline}, + ) + deps.launch_session( + { + "voiceBridge": "session", + "connectionId": connection_id, + "uid": claims["uid"], + "deadline": deadline, + } + ) + return {"statusCode": 200} + + +def _frame(event: dict, connection_id: str, deps: Deps) -> dict: + """Inbound audio frame: forwarded only for a connection that passed the gate.""" + if deps.sessions.get_session(connection_id) is None: + return {"statusCode": 403} + deps.sessions.store_frame(connection_id, event.get("body") or "") + return {"statusCode": 200} + + +# --- production wiring (boto3; built lazily, unused under pytest) ----------- + + +#: Constant partition key every session-metadata item lives under. Frame +#: items keep their own per-connection partition (``SESSION#``), +#: so this key never collides with them — Query-ing it touches only the +#: handful of metadata rows the concurrency gate cares about, never the +#: thousands of frame rows the hot path writes. +_ACTIVE_PK = "ACTIVE" + + +class DynamoSessionStore: + """Sessions + inbound frames in the one PAY_PER_REQUEST table. + + Item shapes: ``PK=ACTIVE``, ``SK=`` for the session-metadata + row (a constant partition key on purpose — see ``count_active`` below); + ``PK=SESSION#``, ``SK=FRAME##`` for + inbound audio frames (ordering is client-``seq`` authoritative — see + relay.order_frames; the SK only approximates arrival). Every item + carries an ``expires_at`` TTL so a crashed session leaves nothing behind + and storage cost stays ~zero. + """ + + def __init__(self, table: Any): + self._table = table + + def count_active(self, now: int) -> int: + # Metadata lives under the constant PK=ACTIVE, so this is a Query + # against a single partition — it reads only metadata items, never + # the frame items (PK=SESSION#) sharing this table. + # Frames are the hot path (thousands of writes per session), so a + # Scan here would have cost/latency growing with frame volume; this + # Query's cost is bounded by the (small, cap-limited) session count. + result = self._table.query( + Select="COUNT", + KeyConditionExpression="PK = :active", + FilterExpression="expires_at > :now", + ExpressionAttributeValues={":active": _ACTIVE_PK, ":now": now}, + ) + return int(result.get("Count", 0)) + + def put_session(self, connection_id: str, record: dict) -> None: + item = { + "PK": _ACTIVE_PK, + "SK": connection_id, + "expires_at": record["deadline"] + _TTL_GRACE_SECONDS, + **record, + } + self._table.put_item(Item=item) + + def get_session(self, connection_id: str) -> dict | None: + result = self._table.get_item(Key={"PK": _ACTIVE_PK, "SK": connection_id}) + return result.get("Item") + + def delete_session(self, connection_id: str) -> None: + self._table.delete_item(Key={"PK": _ACTIVE_PK, "SK": connection_id}) + + def store_frame(self, connection_id: str, body: str) -> None: + self._table.put_item( + Item={ + "PK": f"SESSION#{connection_id}", + "SK": f"FRAME#{int(time.time() * 1000):013d}#{uuid.uuid4().hex[:8]}", + "body": body, + "expires_at": int(time.time()) + 120, # audio staler than 2min is useless + } + ) + + def consume_frames(self, connection_id: str) -> list[str]: + """Read-and-delete pending frames (the session holder's inbound poll).""" + result = self._table.query( + KeyConditionExpression="PK = :pk AND begins_with(SK, :prefix)", + ExpressionAttributeValues={ + ":pk": f"SESSION#{connection_id}", + ":prefix": "FRAME#", + }, + ConsistentRead=True, + ) + items = result.get("Items", []) + for item in items: + self._table.delete_item(Key={"PK": item["PK"], "SK": item["SK"]}) + return [item.get("body", "") for item in items] + + +def _store(): + import boto3 + + table = boto3.resource("dynamodb").Table(os.environ[config.SESSIONS_TABLE_ENV]) + return DynamoSessionStore(table) + + +def _launch_session(payload: dict) -> None: + """Fire-and-forget self-invoke: the holder outlives this router invocation.""" + import boto3 + + boto3.client("lambda").invoke( + FunctionName=os.environ[config.SELF_FUNCTION_NAME_ENV], + InvocationType="Event", + Payload=json.dumps(payload).encode("utf-8"), + ) + + +def _run_session(payload: dict) -> dict: + """Session-holder mode: hold the Bedrock stream, pump both directions.""" + import asyncio + + import boto3 + + from . import relay + + connection_id = payload["connectionId"] + store = _store() + gateway = boto3.client( + "apigatewaymanagementapi", endpoint_url=os.environ[config.CALLBACK_URL_ENV] + ) + + async def _session() -> dict: + bedrock = await relay.open_stream( + os.environ.get(config.MODEL_ID_ENV, config.NOVA_SONIC_MODEL_ID), + os.environ.get("AWS_REGION", "us-east-1"), + ) + + async def next_frames() -> list[str]: + return relay.order_frames(store.consume_frames(connection_id)) + + def send_downstream(message: dict) -> None: + gateway.post_to_connection( + ConnectionId=connection_id, Data=json.dumps(message).encode("utf-8") + ) + + return await relay.pump( + bedrock, + next_frames, + send_downstream, + now=lambda: int(time.time()), + deadline=int(payload["deadline"]), + prompt_name=f"learn-voice-{connection_id}", + system_prompt=( + "You are the learn platform's voice tutor. Be brief, warm, and " + "correct the learner gently." + ), + ) + + try: + summary = asyncio.run(_session()) + finally: + store.delete_session(connection_id) + try: + gateway.delete_connection(ConnectionId=connection_id) + except Exception: # noqa: BLE001 - client may already be gone; that's fine + pass + return summary + + +def _default_deps() -> Deps: + return Deps( + secret=os.environ.get(config.VOICE_TOKEN_SECRET_ENV, ""), + max_concurrent=int( + os.environ.get( + config.MAX_CONCURRENT_SESSIONS_ENV, + config.DEFAULT_MAX_CONCURRENT_VOICE_SESSIONS, + ) + ), + max_session_seconds=int( + os.environ.get(config.MAX_SESSION_SECONDS_ENV, config.DEFAULT_MAX_SESSION_SECONDS) + ), + sessions=_store(), + launch_session=_launch_session, + run_session=_run_session, + now=lambda: int(time.time()), + ) diff --git a/infra/voice_bridge/relay.py b/infra/voice_bridge/relay.py new file mode 100644 index 0000000..c02a581 --- /dev/null +++ b/infra/voice_bridge/relay.py @@ -0,0 +1,266 @@ +"""The bidirectional pump: learner audio up to Nova Sonic 2, model audio back. + +This is the code path the spike proved live end-to-end (infra/SPIKE.md): a +plain Python 3.12 process holding Bedrock's InvokeModelWithBidirectionalStream +over HTTP/2 via the experimental ``aws_sdk_bedrock_runtime`` SDK, real speech +in, recognized transcript + spoken answer back. + +``pump`` is written against three injected seams so the whole loop runs under +pytest with zero AWS: + +- a *bedrock* object (``send_event``/``receive_event``/``close``), +- ``next_frames`` — an async callable draining inbound base64 audio chunks + (production: the DynamoDB frame rows the $default route wrote), +- ``send_downstream`` — a callable receiving ``{"kind", "content"}`` dicts + (production: PostToConnection back through the WebSocket). + +``open_stream`` is the only untested seam: a direct transcription of the +working spike script, including the two traps it found (the credentials +resolver MUST be set explicitly or the SDK's auth failure is swallowed into a +forever-pending await; ``EnvironmentCredentialsResolver`` is exactly how a +Lambda execution role's credentials surface). +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Awaitable, Callable + +# 16 kHz, 16-bit mono LPCM in; 24 kHz out — the exact shapes the spike used. +AUDIO_INPUT_CONFIGURATION = { + "mediaType": "audio/lpcm", + "sampleRateHertz": 16000, + "sampleSizeBits": 16, + "channelCount": 1, + "audioType": "SPEECH", + "encoding": "base64", +} +AUDIO_OUTPUT_CONFIGURATION = { + "mediaType": "audio/lpcm", + "sampleRateHertz": 24000, + "sampleSizeBits": 16, + "channelCount": 1, + "voiceId": "matthew", + "encoding": "base64", + "audioType": "SPEECH", +} +AUDIO_CONTENT_NAME = "learner-audio" + + +def setup_events(prompt_name: str, *, system_prompt: str) -> list[dict]: + """The session-opening event sequence, exactly as the spike sent it.""" + return [ + { + "event": { + "sessionStart": { + "inferenceConfiguration": {"maxTokens": 1024, "topP": 0.9, "temperature": 0.7} + } + } + }, + { + "event": { + "promptStart": { + "promptName": prompt_name, + "textOutputConfiguration": {"mediaType": "text/plain"}, + "audioOutputConfiguration": AUDIO_OUTPUT_CONFIGURATION, + } + } + }, + { + "event": { + "contentStart": { + "promptName": prompt_name, + "contentName": "system", + "type": "TEXT", + "interactive": True, + "role": "SYSTEM", + "textInputConfiguration": {"mediaType": "text/plain"}, + } + } + }, + { + "event": { + "textInput": { + "promptName": prompt_name, + "contentName": "system", + "content": system_prompt, + } + } + }, + {"event": {"contentEnd": {"promptName": prompt_name, "contentName": "system"}}}, + { + "event": { + "contentStart": { + "promptName": prompt_name, + "contentName": AUDIO_CONTENT_NAME, + "type": "AUDIO", + "interactive": True, + "role": "USER", + "audioInputConfiguration": AUDIO_INPUT_CONFIGURATION, + } + } + }, + ] + + +def audio_input_event(prompt_name: str, b64: str) -> dict: + return { + "event": { + "audioInput": { + "promptName": prompt_name, + "contentName": AUDIO_CONTENT_NAME, + "content": b64, + } + } + } + + +def order_frames(bodies: list[str]) -> list[str]: + """Order inbound WS frame bodies by their client-assigned ``seq``. + + $default invocations can land concurrently, so arrival order is only + approximate — the browser numbers each frame (``{"seq": n, "audio": + base64}``) and this puts them back in order. Malformed bodies are dropped, + never fatal: one bad frame must not kill a live conversation. + """ + parsed: list[tuple[int, str]] = [] + for body in bodies: + try: + frame = json.loads(body) + parsed.append((int(frame["seq"]), str(frame["audio"]))) + except (ValueError, KeyError, TypeError): + continue + return [audio for _seq, audio in sorted(parsed)] + + +async def pump( + bedrock: Any, + next_frames: Callable[[], Awaitable[list[str]]], + send_downstream: Callable[[dict], Any], + *, + now: Callable[[], int], + deadline: int, + prompt_name: str, + system_prompt: str, + idle_sleep: float = 0.05, +) -> dict: + """Relay until the deadline, the stream closing, or the caller's cancel. + + The deadline is the per-session cap (MaxSessionSeconds, enforced again by + the Lambda timeout as a backstop) — the session ends *by construction*, + it does not rely on the client hanging up. + """ + for event in setup_events(prompt_name, system_prompt=system_prompt): + await bedrock.send_event(event) + + queue: asyncio.Queue = asyncio.Queue() + + async def _receiver() -> None: + while True: + event = await bedrock.receive_event() + await queue.put(event) + if event is None: + return + + receiver = asyncio.create_task(_receiver()) + frames_sent = 0 + events_received = 0 + ended = "deadline" + try: + while True: + if now() >= deadline: + ended = "deadline" + break + for b64 in await next_frames(): + await bedrock.send_event(audio_input_event(prompt_name, b64)) + frames_sent += 1 + try: + event = await asyncio.wait_for( + queue.get(), timeout=idle_sleep if idle_sleep > 0 else 0.01 + ) + except asyncio.TimeoutError: + continue + if event is None: + ended = "stream-closed" + break + events_received += 1 + body = event.get("event", {}) + name = next(iter(body), "") + if name == "audioOutput": + send_downstream( + {"kind": "audio", "content": body["audioOutput"].get("content", "")} + ) + elif name == "textOutput": + send_downstream({"kind": "text", "content": body["textOutput"].get("content", "")}) + finally: + receiver.cancel() + try: + await bedrock.send_event({"event": {"promptEnd": {"promptName": prompt_name}}}) + await bedrock.send_event({"event": {"sessionEnd": {}}}) + except Exception: # noqa: BLE001 - a closed stream must not mask the summary + pass + await bedrock.close() + return {"ended": ended, "frames_sent": frames_sent, "events_received": events_received} + + +class BedrockStream: + """Thin adapter from the experimental SDK's stream to pump's three seams.""" + + def __init__(self, stream: Any, receiver: Any): + self._stream = stream + self._receiver = receiver + + async def send_event(self, payload: dict) -> None: + from aws_sdk_bedrock_runtime.models import ( + BidirectionalInputPayloadPart, + InvokeModelWithBidirectionalStreamInputChunk, + ) + + chunk = InvokeModelWithBidirectionalStreamInputChunk( + value=BidirectionalInputPayloadPart(bytes_=json.dumps(payload).encode("utf-8")) + ) + await self._stream.input_stream.send(chunk) + + async def receive_event(self): + result = await self._receiver.receive() + if result is None: + return None + part = getattr(result, "value", None) + raw = getattr(part, "bytes_", None) if part else None + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + async def close(self) -> None: + await self._stream.input_stream.close() + + +async def open_stream(model_id: str, region: str) -> BedrockStream: + """Open the live bidirectional stream — the spike script, verbatim. + + Lazy imports: the experimental SDK exists only in the deployed Lambda + bundle (infra/voice_bridge/requirements.txt), never in learn-cli's venv. + """ + from aws_sdk_bedrock_runtime.client import ( + BedrockRuntimeClient, + InvokeModelWithBidirectionalStreamOperationInput, + ) + from aws_sdk_bedrock_runtime.config import Config + from smithy_aws_core.identity import EnvironmentCredentialsResolver + + config = Config( + endpoint_uri=f"https://bedrock-runtime.{region}.amazonaws.com", + region=region, + # Spike finding: omit this and the SDK swallows its auth error into a + # never-resolving await. The env resolver reads the standard AWS_* + # variables — exactly how the Lambda execution role's SigV4 + # credentials arrive (API keys are NOT accepted by this operation). + aws_credentials_identity_resolver=EnvironmentCredentialsResolver(), + ) + client = BedrockRuntimeClient(config=config) + stream = await client.invoke_model_with_bidirectional_stream( + InvokeModelWithBidirectionalStreamOperationInput(model_id=model_id) + ) + output = await stream.await_output() + return BedrockStream(stream, output[1]) diff --git a/infra/voice_bridge/tokens.py b/infra/voice_bridge/tokens.py new file mode 100644 index 0000000..7755276 --- /dev/null +++ b/infra/voice_bridge/tokens.py @@ -0,0 +1,115 @@ +"""Short-lived voice tokens — the verify side of learn-api's mint (task t16). + +Byte-for-byte symmetric with ``workers/learn-api/src/session.js``: + + token = base64url(JSON payload) + "." + base64url(signature) + signature = HMAC-SHA256(secret, base64url(JSON payload)) + +both base64url segments unpadded, the HMAC computed over the *encoded body +string*. The Worker mints with the same shared secret (a template Parameter +here, a Worker secret there); this module only ever verifies. + +The payload carries ``{v, scope, uid, approved, iat, exp, sid}``. Three claims +distinguish a voice token from the session tokens whose format it shares: + +- ``scope: "voice"`` — a learn-api *session* token must never open the + bridge (no session-cookie reuse; the plan's t4 acceptance verbatim); +- ``approved: true`` — minted only for learners the admin approved for the + Bedrock tier, making the bridge entry the approval gate (h7 groundwork); +- a short ``exp`` — minutes, not the session's hour. + +``verify_voice_token`` returns the payload dict when every check passes and +``None`` otherwise — the session.js contract. It never raises on bad input. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +import uuid + +TOKEN_VERSION = 1 +TOKEN_SCOPE = "voice" + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _sign(secret: str, body: str) -> str: + digest = hmac.new(secret.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest() + return _b64url(digest) + + +def mint_voice_token( + secret: str, + uid: str | int, + *, + approved: bool, + ttl_seconds: int, + now: int | None = None, + scope: str = TOKEN_SCOPE, +) -> str: + """Mint a voice token — the Python parity twin of the Worker's minter. + + Production minting happens in learn-api (t16); this helper exists so the + verify side can be tested against the exact format the Worker produces, + and doubles as an operator tool for local smoke tests. + """ + if not secret: + raise ValueError("voice-token secret is not configured") + if approved is not True: + raise ValueError("voice tokens are minted for approved learners only") + iat = int(time.time()) if now is None else int(now) + payload = { + "v": TOKEN_VERSION, + "scope": scope, + "uid": str(uid), + "approved": True, + "iat": iat, + "exp": iat + int(ttl_seconds), + "sid": str(uuid.uuid4()), + } + body = _b64url(json.dumps(payload).encode("utf-8")) + return f"{body}.{_sign(secret, body)}" + + +def verify_voice_token(secret: str | None, token: object, *, now: int | None = None): + """Return the payload if the token is valid, current, and approved — else ``None``.""" + if not secret or not isinstance(secret, str): + return None + if not token or not isinstance(token, str): + return None + dot = token.rfind(".") + if dot <= 0: + return None + body, signature = token[:dot], token[dot + 1 :] + if not hmac.compare_digest(signature, _sign(secret, body)): + return None + try: + payload = json.loads(_b64url_decode(body)) + except (ValueError, UnicodeDecodeError): + return None + if not isinstance(payload, dict): + return None + if payload.get("v") != TOKEN_VERSION: + return None + if payload.get("scope") != TOKEN_SCOPE: + return None + if payload.get("approved") is not True: + return None + uid = payload.get("uid") + if not uid or not isinstance(uid, str): + return None + exp = payload.get("exp") + current = int(time.time()) if now is None else int(now) + if not isinstance(exp, (int, float)) or exp <= current: + return None + return payload diff --git a/learn/cli/__init__.py b/learn/cli/__init__.py index e82cb30..bcee153 100644 --- a/learn/cli/__init__.py +++ b/learn/cli/__init__.py @@ -62,6 +62,7 @@ def _argv_has_json(argv: list[str] | None) -> bool: def _build_parser() -> argparse.ArgumentParser: + from learn.cli._commands import admin as _admin_group from learn.cli._commands import auth as _auth_group from learn.cli._commands import cli as _cli_group from learn.cli._commands import doctor as _doctor_cmd @@ -104,6 +105,7 @@ def _build_parser() -> argparse.ArgumentParser: _progress_cmd.register(sub) _next_cmd.register(sub) _record_cmd.register(sub) + _admin_group.register(sub) # Register your own noun groups here: # from learn.cli._commands import my_noun as _my_noun_group # _my_noun_group.register(sub) diff --git a/learn/cli/_commands/admin.py b/learn/cli/_commands/admin.py new file mode 100644 index 0000000..32bb200 --- /dev/null +++ b/learn/cli/_commands/admin.py @@ -0,0 +1,220 @@ +"""``learn admin`` — the admin-only CLI surface (tasks t8 + t9). + +Three verbs today: + +* ``admin learners`` — lists every learner + a cheap per-subject progress + summary via ``GET /api/admin/learners``. Requires a local device-flow + session (``learn auth login``) — the same authenticated-API pattern + ``learn auth``/``learn progress``'s sync path already use (see + :mod:`learn.profile`). Admin-ness itself is a SERVER-SIDE decision (spec + c12/h4, ``workers/learn-api/src/admin.js``): this CLI makes none of its + own — a non-admin token gets whatever structured error the server returns + (403 ``admin_required``), surfaced here as an environment error like any + other failed API call. +* ``admin approve `` / ``admin revoke `` — + grant/withdraw a learner's tutoring tier via ``POST /api/admin/approve`` / + ``POST /api/admin/revoke`` (spec c13, task t9). Same server-side trust + model as ``learners``; the server additionally enforces decision c20 on + approve (the target's consent must cover the CURRENT terms version — a + ``409 consent_stale`` otherwise). Both take effect on the learner's very + next tutor call, no re-login on their side. +* ``admin overview`` — describes this noun (the agent-first rubric requires + an ``overview`` on any noun with action-verbs). +""" + +from __future__ import annotations + +import argparse +from typing import Any, Callable + +from learn.cli._commands.overview import emit_overview +from learn.cli._errors import EXIT_ENV_ERROR, EXIT_SUCCESS, EXIT_USER_ERROR, CliError +from learn.cli._output import emit_result +from learn.profile import ( + ApiError, + admin_approve_learner, + admin_list_learners, + admin_revoke_learner, + load_auth, +) + +#: Shared ``--json`` help text (repeated per subparser below). +_JSON_HELP = "Emit structured JSON." + + +def _admin_sections() -> list[dict[str, object]]: + return [ + { + "title": "Verbs", + "items": [ + "admin learners — list every learner + a cheap per-subject progress summary", + "admin approve — grant a learner the tutoring tier", + "admin revoke — withdraw a learner's tutoring tier", + "admin overview — describe this noun (you are here)", + ], + }, + { + "title": "Authorization", + "items": [ + "requires a local session: run `learn auth login` first", + "admin-ness is decided SERVER-SIDE against a GitHub-id allow-list " + "(spec c12/h4) — a non-admin token gets a 403 from the API, " + "surfaced here as an environment error", + "approve additionally requires the target learner's consent to be " + "CURRENT (decision c20) — the server 409s `consent_stale` otherwise", + "LEARN_API_URL overrides the API base (default https://agentculture.org/learn/api)", + ], + }, + ] + + +def cmd_admin_overview(args: argparse.Namespace) -> int: + emit_overview("learn admin", _admin_sections(), json_mode=bool(getattr(args, "json", False))) + return EXIT_SUCCESS + + +def _render_text(resp: dict[str, Any]) -> str: + lines = [f"learn admin learners — {resp.get('count', 0)} learner(s)", ""] + for learner in resp.get("learners", []) or []: + consent = learner.get("consent") or {} + lines.append( + f"{learner.get('display_name')} (github:{learner.get('github_user_id')}) " + f"[{learner.get('visibility')}]" + ) + lines.append( + f" consent: {learner.get('consent_status')}" + + (f" ({consent.get('terms_version')})" if consent else "") + ) + # t9: the tutoring-tier flag — additive on the server payload; absent + # (an older server) renders as "not approved", the safe reading. + lines.append(f" tutoring: {'approved' if learner.get('approved') else 'not approved'}") + lines.append( + f" records: {learner.get('records_total', 0)} total {learner.get('records', {})}" + ) + return "\n".join(lines) + + +def cmd_admin_learners(args: argparse.Namespace) -> int: + json_mode = bool(getattr(args, "json", False)) + state = load_auth() + if state is None: + raise CliError( + code=EXIT_USER_ERROR, + message="not signed in", + remediation="run `learn auth login` first — an admin must also be signed in", + ) + try: + resp = admin_list_learners(state.token) + except ApiError as err: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not list learners: {err}", + remediation=( + "check network connectivity and LEARN_API_URL, and confirm your GitHub id " + "is on the server's admin allow-list" + ), + ) from err + if json_mode: + emit_result(resp, json_mode=True) + else: + emit_result(_render_text(resp), json_mode=False) + return EXIT_SUCCESS + + +def _admin_mutate( + args: argparse.Namespace, + *, + action: str, + api_call: Callable[[str, str], dict[str, Any]], +) -> int: + """Shared approve/revoke driver (t9) — mirrors cmd_admin_learners exactly: + local session required, one API call, ApiError -> environment error.""" + json_mode = bool(getattr(args, "json", False)) + state = load_auth() + if state is None: + raise CliError( + code=EXIT_USER_ERROR, + message="not signed in", + remediation="run `learn auth login` first — an admin must also be signed in", + ) + try: + resp = api_call(state.token, args.github_user_id) + except ApiError as err: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not {action} learner: {err}", + remediation=( + "check network connectivity and LEARN_API_URL; confirm your GitHub id is on " + "the server's admin allow-list; and (approve only, decision c20) confirm the " + "target learner's consent covers the CURRENT terms version — the server 409s " + "`consent_stale` until they re-accept" + ), + ) from err + if json_mode: + emit_result(resp, json_mode=True) + else: + verdict = "approved" if resp.get("approved") else "not approved" + emit_result( + f"learner {resp.get('github_user_id')}: tutoring {verdict}", + json_mode=False, + ) + return EXIT_SUCCESS + + +def cmd_admin_approve(args: argparse.Namespace) -> int: + return _admin_mutate(args, action="approve", api_call=admin_approve_learner) + + +def cmd_admin_revoke(args: argparse.Namespace) -> int: + return _admin_mutate(args, action="revoke", api_call=admin_revoke_learner) + + +def _no_verb(args: argparse.Namespace) -> int: + # `learn admin` with no sub-verb prints the noun's overview. + return cmd_admin_overview(args) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "admin", + help="Admin-only reads (see 'learn admin overview'); requires `learn auth login`.", + ) + p.add_argument("--json", action="store_true", help=_JSON_HELP) + p.set_defaults(func=_no_verb, json=False) + # Propagate the structured-error parser class so sub-verb parse errors route + # through the error contract (error:/hint: + exit 1), not argparse's default. + noun_sub = p.add_subparsers(dest="admin_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the admin noun and its verbs.") + ov.add_argument("--json", action="store_true", help=_JSON_HELP) + ov.set_defaults(func=cmd_admin_overview) + + learners = noun_sub.add_parser( + "learners", help="List every learner + a cheap per-subject progress summary (admin-only)." + ) + learners.add_argument("--json", action="store_true", help=_JSON_HELP) + learners.set_defaults(func=cmd_admin_learners) + + approve = noun_sub.add_parser( + "approve", + help="Grant a learner the tutoring tier (admin-only; requires their consent " + "to be current — decision c20).", + ) + approve.add_argument( + "github_user_id", + help="The target learner's GitHub user id (see `learn admin learners`).", + ) + approve.add_argument("--json", action="store_true", help=_JSON_HELP) + approve.set_defaults(func=cmd_admin_approve) + + revoke = noun_sub.add_parser( + "revoke", + help="Withdraw a learner's tutoring tier (admin-only; effective on their next " + "tutor call).", + ) + revoke.add_argument( + "github_user_id", + help="The target learner's GitHub user id (see `learn admin learners`).", + ) + revoke.add_argument("--json", action="store_true", help=_JSON_HELP) + revoke.set_defaults(func=cmd_admin_revoke) diff --git a/learn/contract/schemas/practice.json b/learn/contract/schemas/practice.json index e2ce2e7..6ea6aa4 100644 --- a/learn/contract/schemas/practice.json +++ b/learn/contract/schemas/practice.json @@ -50,7 +50,40 @@ "items": { "type": "string" } }, "answer": { "type": "string" }, - "rubric": { "type": "string" } + "rubric": { "type": "string" }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." + } } }, "directive": { diff --git a/learn/contract/schemas/story.json b/learn/contract/schemas/story.json index 8ef2d24..ea7ca91 100644 --- a/learn/contract/schemas/story.json +++ b/learn/contract/schemas/story.json @@ -124,6 +124,39 @@ "rubric": { "type": "string", "description": "Grading guidance for open-ended exercises: what passes, what is partial." + }, + "text": { + "type": "string", + "minLength": 1, + "description": "For the pick-the-right-word cloze variant ONLY: the passage with each blank marked as a `{{blank_id}}` placeholder, one per entry in `blanks`. Omit for a single-blank free-text cloze exercise (use `prompt` with the blank written as `___`, plus a top-level `answer`, exactly as contract 1.0 always allowed) and for every non-cloze type." + }, + "blanks": { + "type": "array", + "minItems": 1, + "description": "For the pick-the-right-word cloze variant ONLY: one entry per `{{blank_id}}` placeholder in `text`, always present together with `text`. Absent for a single-blank free-text cloze exercise and for every non-cloze type.", + "items": { "$ref": "#/$defs/cloze_blank" } + } + } + }, + "cloze_blank": { + "type": "object", + "required": ["id", "options", "answer"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "description": "Matches one `{{id}}` placeholder in the exercise's `text`; unique within the exercise." + }, + "options": { + "type": "array", + "minItems": 2, + "items": { "type": "string", "minLength": 1 }, + "description": "The words the reader picks from for this blank: the correct word plus one or more distractors. Must include `answer`." + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "The correct option for this blank; must be one of `options`." } } } diff --git a/learn/explain/catalog.py b/learn/explain/catalog.py index 7572518..55be06a 100644 --- a/learn/explain/catalog.py +++ b/learn/explain/catalog.py @@ -348,6 +348,96 @@ learn auth status --json """ +_ADMIN = """\ +# learn admin + +The admin-only CLI surface (tasks t8 + t9): `admin learners` lists every +learner plus a cheap per-subject progress summary via +`GET /api/admin/learners`; `admin approve` / `admin revoke` grant or +withdraw a learner's tutoring tier via `POST /api/admin/approve` / +`POST /api/admin/revoke`. All require a local session (`learn auth login`) — +the same authenticated-API pattern `learn auth` uses. Admin-ness itself is a +SERVER-SIDE decision (spec c12/h4, allow-listed GitHub ids enforced in +`workers/learn-api/src/admin.js`): this CLI makes none of its own — a +non-admin token gets whatever structured error the server returns, surfaced +here as an environment error (exit 2). + +## Verbs + +- `learn admin learners` — list every learner + a per-subject progress + summary and their tutoring-tier approval (admin-only; server-enforced). +- `learn admin approve ` — grant the tutoring tier + (server enforces decision c20: the learner's consent must be current). +- `learn admin revoke ` — withdraw the tutoring tier + (effective on the learner's next tutor call; no re-login involved). +- `learn admin overview` — describe this noun (you are here). + +## Usage + + learn auth login + learn admin learners + learn admin approve 20955789 + learn admin revoke 20955789 --json + +`LEARN_API_URL` overrides the API base (default +`https://agentculture.org/learn/api`). +""" + +_ADMIN_LEARNERS = """\ +# learn admin learners + +Lists every learner registered with the learn API, each with `github_user_id`, +`display_name`, `created_at`, `visibility`, tutoring-tier `approved` (t9), +consent status/version, and a per-subject record-count summary — via +`GET /api/admin/learners`. Requires a local session (`learn auth login`); the +server 403s a non-admin token (`admin_required`), surfaced here as an +environment error (exit 2), never a silent empty list. + +## Usage + + learn admin learners + learn admin learners --json +""" + +_ADMIN_APPROVE = """\ +# learn admin approve + +Grants a learner the Bedrock tutoring tier via `POST /api/admin/approve` +(spec c13, task t9). Requires a local session (`learn auth login`); the +server enforces the admin allow-list (403 `admin_required` for anyone else) +AND decision c20 — the target learner's recorded consent must cover the +CURRENT terms version, otherwise the server 409s `consent_stale` and nothing +is written. Both failures surface here as environment errors (exit 2). + +Approval takes effect on the learner's very next `/api/tutor` call — the +Worker reads the flag per request, so the learner does not re-login. Until +approved, a consented learner's tutor calls get a structured +`403 approval_required` and spend zero inference. + +## Usage + + learn admin learners # find the github_user_id + consent status + learn admin approve 20955789 + learn admin approve 20955789 --json +""" + +_ADMIN_REVOKE = """\ +# learn admin revoke + +Withdraws a learner's Bedrock tutoring tier via `POST /api/admin/revoke` +(task t9). Requires a local session (`learn auth login`); admin-only +(server-enforced allow-list, 403 `admin_required` otherwise). Idempotent — a +never-approved learner revokes to the same state. Takes effect on the +learner's very next `/api/tutor` call (the Worker reads the flag per +request): they get a structured `403 approval_required` and spend zero +inference, while progress/records/export keep working. + +## Usage + + learn admin revoke 20955789 + learn admin revoke 20955789 --json +""" + _PROGRESS = """\ # learn progress @@ -434,4 +524,9 @@ ("progress",): _PROGRESS, ("next",): _NEXT, ("record",): _RECORD, + ("admin",): _ADMIN, + ("admin", "overview"): _ADMIN, + ("admin", "learners"): _ADMIN_LEARNERS, + ("admin", "approve"): _ADMIN_APPROVE, + ("admin", "revoke"): _ADMIN_REVOKE, } diff --git a/learn/profile/__init__.py b/learn/profile/__init__.py index a86bd69..1ef2b9a 100644 --- a/learn/profile/__init__.py +++ b/learn/profile/__init__.py @@ -27,8 +27,9 @@ * :class:`SyncState`, :func:`load_sync_state`, :func:`save_sync_state` — the sync cursor. * :func:`device_start`, :func:`device_poll`, :func:`device_logout`, - :func:`fetch_me`, :func:`push_record`, :class:`ApiError`, :func:`api_base` — - the learn API client. + :func:`fetch_me`, :func:`push_record`, :func:`admin_list_learners`, + :func:`admin_approve_learner`, :func:`admin_revoke_learner`, + :class:`ApiError`, :func:`api_base` — the learn API client. * :class:`SyncResult`, :func:`push_pending`, :func:`pending_sync_count` — the one-way sync push. """ @@ -40,6 +41,9 @@ DEFAULT_API_URL, DEFAULT_TIMEOUT, ApiError, + admin_approve_learner, + admin_list_learners, + admin_revoke_learner, api_base, device_logout, device_poll, @@ -87,6 +91,9 @@ "device_logout", "fetch_me", "push_record", + "admin_list_learners", + "admin_approve_learner", + "admin_revoke_learner", "DEFAULT_API_URL", "API_URL_ENV", "DEFAULT_TIMEOUT", diff --git a/learn/profile/_api.py b/learn/profile/_api.py index b52afe0..89181e4 100644 --- a/learn/profile/_api.py +++ b/learn/profile/_api.py @@ -133,3 +133,47 @@ def push_record( if mastery is not None: payload["mastery"] = mastery return _request("POST", "/record", token=token, payload=payload, timeout=timeout) + + +def admin_list_learners(token: str, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: + """Admin-only: ``GET /api/admin/learners`` — every learner + a cheap + per-subject progress summary. The server enforces the GitHub-id + allow-list (spec c12/h4); a non-admin token gets an ``ApiError`` wrapping + the server's ``403 admin_required`` here, same as any other HTTP failure + — this client makes no admin decision of its own. + """ + return _request("GET", "/admin/learners", token=token, timeout=timeout) + + +def admin_approve_learner( + token: str, github_user_id: str, *, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """Admin-only: ``POST /api/admin/approve`` — grant a learner the tutoring + tier (spec c13, task t9). The server enforces both the admin allow-list + AND decision c20 (the target's consent must cover the CURRENT terms + version — a ``409 consent_stale`` otherwise); either failure surfaces as + an ``ApiError`` here, same as any other HTTP failure. + """ + return _request( + "POST", + "/admin/approve", + token=token, + payload={"github_user_id": github_user_id}, + timeout=timeout, + ) + + +def admin_revoke_learner( + token: str, github_user_id: str, *, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """Admin-only: ``POST /api/admin/revoke`` — withdraw a learner's tutoring + tier (task t9). Takes effect on the learner's very next tutor call; the + server reads the flag per request, no re-login involved. + """ + return _request( + "POST", + "/admin/revoke", + token=token, + payload={"github_user_id": github_user_id}, + timeout=timeout, + ) diff --git a/learn/subjects/conformance.py b/learn/subjects/conformance.py index 1c57e3c..15b83ca 100644 --- a/learn/subjects/conformance.py +++ b/learn/subjects/conformance.py @@ -19,24 +19,40 @@ * ``verb-overview`` / ``verb-progress`` / ``verb-advice`` / ``verb-story-list`` — each read-only verb responds with a schema-valid, version-compatible payload; +* ``cloze-items`` — every pick-the-right-word cloze exercise (``type: "cloze"`` + carrying ``text``/``blanks``, see ``docs/specs/subject-plugin-contract.md`` + §3.6.1) declared anywhere in the subject's stories is well-formed: each blank + has >=2 options that include its answer, blank ids are unique within the + exercise and match a ``{{blank_id}}`` placeholder in ``text`` 1:1, and the + exercise carries a non-empty ``item_id``. A subject with no such items passes + trivially — this check never penalizes pre-cloze content; * ``error-contract`` — a deliberately bad invocation exits non-zero with the ``{code, message, remediation}`` shape on stderr, an empty stdout, and an exit code equal to the payload's ``code``. -Mutating/learner-authoring verbs (``lesson``, ``practice``, ``record``, -``story read``) are validated by golden payloads in each subject repo's own CI; -the runtime gate stays read-only and safe to run against any learner state. +Mutating/learner-authoring verbs (``lesson``, ``practice``, ``record``) are +validated by golden payloads in each subject repo's own CI; the runtime gate +stays read-only. ``story read`` is the one exception: since ``cloze-items`` +needs each story's full exercise bodies (not just ``story list``'s summaries) +to verify declared blanks, this module additionally reads every listed story — +still read-only and safe against any learner state (``story read`` may only +advance the probe learner's own reading position, per the contract's state +table), driven with the same dedicated :data:`PROBE_LEARNER`. """ from __future__ import annotations import json +import re import subprocess # nosec B404 - driving subjects as subprocesses is the design from typing import Any from learn.contract import CONTRACT_VERSION, validate from learn.subjects import SubjectEntry, resolve_executable +#: Matches a `{{blank_id}}` placeholder inside a cloze exercise's `text`. +_BLANK_PLACEHOLDER_RE = re.compile(r"\{\{([^{}]+)\}\}") + #: Learner id used for learner-scoped probes. Read-only verbs never mutate #: state, so this never touches a real learner's ledger. PROBE_LEARNER = "learn-conformance-probe" @@ -229,6 +245,260 @@ def _probe_error_contract(exe: str, entry: SubjectEntry, timeout: float) -> dict ) +def _is_cloze_blanks_item(exercise: Any) -> bool: + """True for the pick-the-right-word cloze variant (has ``text`` or ``blanks``). + + A legacy single-blank free-text cloze exercise (``type: "cloze"`` with only + ``prompt``/``answer``, no ``text``/``blanks``) is NOT this variant — it keeps + validating and rendering exactly as contract 1.0 always allowed, untouched + by this check. + """ + return ( + isinstance(exercise, dict) + and exercise.get("type") == "cloze" + and ("text" in exercise or "blanks" in exercise) + ) + + +def _validate_cloze_top_level_fields(prefix: str, exercise: dict[str, Any], text: Any) -> list[str]: + """Check the exercise-level fields: `text` is a non-empty string, `item_id` is set. + + Independent of the per-blank checks below — both run even if the other fails. + """ + errors: list[str] = [] + if not isinstance(text, str) or not text.strip(): + errors.append(f"{prefix}: `text` must be a non-empty string") + if not exercise.get("item_id"): + errors.append(f"{prefix}: missing `item_id` (the join key `record --item` expects)") + return errors + + +def _validate_cloze_blank_id( + bpath: str, bid: Any, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """Check one blank's `id`: present, unique in the exercise, matches a `text` placeholder. + + Records a valid, non-duplicate id into ``seen_ids`` (mutated in place) so the + caller can later find `text` placeholders with no matching blank. + """ + if not isinstance(bid, str) or not bid: + return [f"{bpath}: missing/empty `id`"] + if bid in seen_ids: + return [f"{bpath}: duplicate blank id '{bid}' within this exercise"] + seen_ids.add(bid) + if bid not in placeholder_ids: + return [f"{bpath}: id '{bid}' has no matching {{{{{bid}}}}} placeholder in `text`"] + return [] + + +def _validate_cloze_blank_answer(bpath: str, options: Any, answer: Any) -> list[str]: + """Check one blank's `options` (>=2 words) and `answer` (present, among `options`).""" + errors: list[str] = [] + if not isinstance(options, list) or len(options) < 2: + errors.append(f"{bpath}: `options` must list at least 2 words") + if not isinstance(answer, str) or not answer: + errors.append(f"{bpath}: missing/empty `answer`") + elif isinstance(options, list) and answer not in options: + errors.append(f"{bpath}: `answer` {answer!r} is not among its own `options`") + return errors + + +def _validate_cloze_blank( + bpath: str, blank: Any, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """Validate one `blanks[i]` entry in full: shape, then id, then options/answer.""" + if not isinstance(blank, dict): + return [f"{bpath}: must be an object"] + errors = _validate_cloze_blank_id(bpath, blank.get("id"), placeholder_ids, seen_ids) + errors.extend(_validate_cloze_blank_answer(bpath, blank.get("options"), blank.get("answer"))) + return errors + + +def _validate_cloze_orphan_placeholders( + prefix: str, placeholder_ids: set[str], seen_ids: set[str] +) -> list[str]: + """`text` placeholders with no matching `blanks` entry, sorted for stable output.""" + return [ + f"{prefix}: `text` placeholder {{{{{orphan}}}}} has no matching `blanks` entry" + for orphan in sorted(placeholder_ids - seen_ids) + ] + + +def _validate_cloze_exercise(story_id: str, exercise: dict[str, Any]) -> list[str]: + """Semantic checks the mini JSON-Schema validator can't express: cross-field + rules (an option list contains its own answer, blank ids are unique and + match `text`'s placeholders 1:1). Structural shape (types, minItems, ...) is + already covered by :func:`learn.contract.validate` against the schema. + """ + exercise_id = exercise.get("id", "?") + prefix = f"story '{story_id}' exercise '{exercise_id}'" + + text = exercise.get("text") + blanks = exercise.get("blanks") + if text is None or blanks is None: + return [f"{prefix}: a pick-the-right-word cloze item needs BOTH `text` and `blanks`"] + + errors = _validate_cloze_top_level_fields(prefix, exercise, text) + if not isinstance(blanks, list) or not blanks: + errors.append(f"{prefix}: `blanks` must be a non-empty array") + return errors + + placeholder_ids = set(_BLANK_PLACEHOLDER_RE.findall(text)) if isinstance(text, str) else set() + seen_ids: set[str] = set() + for i, blank in enumerate(blanks): + errors.extend( + _validate_cloze_blank(f"{prefix} blanks[{i}]", blank, placeholder_ids, seen_ids) + ) + + errors.extend(_validate_cloze_orphan_placeholders(prefix, placeholder_ids, seen_ids)) + return errors + + +def _read_story_exercises( + exe: str, entry: SubjectEntry, story_id: str, timeout: float +) -> tuple[list[Any], str | None]: + """Read one story via ``story read`` and return (exercises, error). + + On any failure to read/parse the story, returns ``([], )``. + On success, returns ``(, None)`` — + a malformed/missing ``exercises`` field is not itself an error here; it just + yields nothing to iterate (the schema check on ``story_read`` catches that + shape drift, this probe only cares about cloze content). + """ + result = _run(exe, entry, ("story", "read", story_id), learner=True, timeout=timeout) + if isinstance(result, str): + return [], f"story '{story_id}': could not read to verify cloze items ({result})" + rc, out, _err = result + if rc != 0: + return [], f"story '{story_id}': story read exited {rc}; could not verify cloze items" + try: + payload = json.loads(out) + except json.JSONDecodeError: + return [], f"story '{story_id}': story read did not emit valid JSON" + story = payload.get("story") if isinstance(payload, dict) else None + exercises = story.get("exercises") if isinstance(story, dict) else None + return (exercises if isinstance(exercises, list) else []), None + + +def _check_cloze_exercise_id_reuse( + exercise_id: str, story_id: str, first_story_for_exercise_id: dict[str, str] +) -> str | None: + """Flag a cloze exercise ``id`` already seen under a different story. + + Records the first (story_id) an id was seen under into + ``first_story_for_exercise_id`` (mutated in place). + """ + prior = first_story_for_exercise_id.get(exercise_id) + if prior is not None and prior != story_id: + return ( + f"cloze exercise id '{exercise_id}' is reused in both '{prior}' and " + f"'{story_id}' — exercise ids must be unique" + ) + first_story_for_exercise_id.setdefault(exercise_id, story_id) + return None + + +def _validate_cloze_in_story( + story_id: str, exercise: dict[str, Any], first_story_for_exercise_id: dict[str, str] +) -> list[str]: + """Validate one cloze exercise's well-formedness plus its id's cross-story uniqueness.""" + errors = _validate_cloze_exercise(story_id, exercise) + exercise_id = exercise.get("id") + if isinstance(exercise_id, str) and exercise_id: + reuse_error = _check_cloze_exercise_id_reuse( + exercise_id, story_id, first_story_for_exercise_id + ) + if reuse_error is not None: + errors.append(reuse_error) + return errors + + +def _probe_story_cloze_items( + story_id: str, exercises: list[Any], first_story_for_exercise_id: dict[str, str] +) -> tuple[list[str], int]: + """Validate every pick-the-right-word cloze exercise in one story's exercise list. + + Returns ``(errors, found_count)`` — exercises that aren't the cloze-blanks + variant (see :func:`_is_cloze_blanks_item`) are skipped, uncounted. + """ + errors: list[str] = [] + found = 0 + for exercise in exercises: + if not _is_cloze_blanks_item(exercise): + continue + found += 1 + errors.extend(_validate_cloze_in_story(story_id, exercise, first_story_for_exercise_id)) + return errors, found + + +def _summarize_cloze_check( + cid: str, errors: list[str], found: int, remediation: str +) -> dict[str, Any]: + """Turn accumulated per-story errors/found-count into the final `cloze-items` check. + + A subject with no cloze-blanks items (``found == 0``) passes trivially, even + if some story failed to read along the way — that's a content-verification + no-op, not this check's failure to report. + """ + if found == 0: + return _check(cid, True, "no pick-the-right-word cloze items declared (nothing to verify)") + if errors: + return _check( + cid, + False, + f"{errors[0]} ({len(errors)} issue(s) total across {found} cloze item(s))", + remediation=remediation, + ) + return _check( + cid, True, f"{found} pick-the-right-word cloze item(s) verified: well-formed blanks" + ) + + +def _probe_cloze_items( + exe: str, + entry: SubjectEntry, + story_list_payload: dict[str, Any] | None, + timeout: float, +) -> dict[str, Any]: + """Verify every pick-the-right-word cloze exercise declared in any story. + + Reads each story ``story list`` named (via ``story read``) and checks every + cloze exercise carrying ``text``/``blanks`` for well-formedness. A subject + that declares none passes trivially — this never penalizes subjects that + haven't shipped cloze content yet (or use only the legacy free-text form). + """ + cid = "cloze-items" + remediation = ( + "fix the cloze item: `text` and `blanks` must both be present, each blank needs a " + "unique `id` matching one `{{id}}` placeholder in `text`, >=2 `options`, and an " + "`answer` that is one of those `options`" + ) + stories = story_list_payload.get("stories") if isinstance(story_list_payload, dict) else None + if not isinstance(stories, list): + # story-list itself failed/was unavailable — that's `verb-story-list`'s + # failure to report, not this check's; nothing to verify here. + return _check(cid, True, "story list unavailable; skipped cloze verification") + + errors: list[str] = [] + first_story_for_exercise_id: dict[str, str] = {} + found = 0 + for summary in stories: + story_id = summary.get("id") if isinstance(summary, dict) else None + if not isinstance(story_id, str) or not story_id: + continue + exercises, read_error = _read_story_exercises(exe, entry, story_id, timeout) + if read_error is not None: + errors.append(read_error) + continue + story_errors, story_found = _probe_story_cloze_items( + story_id, exercises, first_story_for_exercise_id + ) + errors.extend(story_errors) + found += story_found + + return _summarize_cloze_check(cid, errors, found, remediation) + + def run_conformance(entry: SubjectEntry, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """Drive ``entry``'s verbs and return the ``subject_doctor`` payload. @@ -256,12 +526,16 @@ def run_conformance(entry: SubjectEntry, *, timeout: float = DEFAULT_TIMEOUT) -> doctor_checks, _pin = _probe_doctor(exe, entry, timeout) checks.extend(doctor_checks) + story_list_payload: dict[str, Any] | None = None for cid, verb, schema, learner in _READONLY_PROBES: - check, _payload_out = _probe_verb( + check, payload_out = _probe_verb( exe, entry, cid, verb, schema, learner=learner, timeout=timeout ) checks.append(check) + if cid == "verb-story-list" and check["passed"]: + story_list_payload = payload_out + checks.append(_probe_cloze_items(exe, entry, story_list_payload, timeout)) checks.append(_probe_error_contract(exe, entry, timeout)) return _payload(entry, checks) diff --git a/pyproject.toml b/pyproject.toml index e6de587..f7f120f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "learn-cli" -version = "0.5.4" +version = "0.6.0" description = "A CLI, MCP server, and web front for learning — a hosted site where humans and agents learn a subject step by step. Fronts the french-cli and spanish-cli language tutors, and generalizes to other learnable domains such as leading teams of agents. The learn command is the local operator surface and how agents drive it." readme = "README.md" license = "Apache-2.0" @@ -42,6 +42,9 @@ dev = [ "isort>=5.12.0", "black>=23.7.0", "teken>=0.8", + # tests/test_voice_bridge_template.py parses infra/template.yaml as plain + # YAML (the long-form-intrinsics rule) — test-only, not a runtime dep. + "pyyaml>=6.0", ] [tool.coverage.run] diff --git a/shared/terms-version.mjs b/shared/terms-version.mjs new file mode 100644 index 0000000..525808f --- /dev/null +++ b/shared/terms-version.mjs @@ -0,0 +1,30 @@ +// Single source of truth for the published Terms of Use / Privacy Policy +// version (spec "agentculture-org-learn-is-now-a-consent-first-role", #11; +// plan task t1). Two consumers import this file, and only this file: +// +// - the Astro site's pages (site-astro/src/lib/terms.ts -> src/pages/terms/, +// src/pages/privacy/) — renders the version + effective date visibly on +// both policy pages. +// - the Cloudflare Worker (workers/learn-api/src/terms.js) — a later wave +// (plan task t6) stamps the consent row's `terms_version` with this exact +// value and treats a mismatch as "re-consent required." +// +// Deliberately a plain ES module, not a `.json` file: `import x from +// "./f.json"` needs an import-assertion clause (`with { type: "json" }` / +// the older `assert { type: "json" }`) whose required syntax has moved +// across recent Node versions, and this file is loaded three different ways +// (Node's own `--test` runner in workers/learn-api, Vite/Rollup for `astro +// build`, esbuild inside `wrangler`/`wrangler deploy`). A plain `export +// const` sidesteps that version skew entirely — every one of those three +// loaders already understands plain ESM with zero extra configuration. +// +// Bumping TERMS_VERSION (and TERMS_EFFECTIVE_DATE alongside it) is what +// forces re-consent: a learner's stored `consent.terms_version` must equal +// this value exactly, or the next authenticated request routes back to the +// consent screen (spec requirement "Consent record", honesty condition h2). +// Keep the date in ISO-8601 (YYYY-MM-DD) to match every other timestamp +// convention in this repo (schema.sql's `created_at`/`updated_at`, the +// ledger's `at`). + +export const TERMS_VERSION = "1.0.0"; +export const TERMS_EFFECTIVE_DATE = "2026-07-11"; diff --git a/site-astro/package.json b/site-astro/package.json index e0ff5de..924310a 100644 --- a/site-astro/package.json +++ b/site-astro/package.json @@ -10,9 +10,10 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", - "check": "npm run check:pages && npm run check:static-auth", + "check": "npm run check:pages && npm run check:static-auth && npm run check:tutor-logic", "check:pages": "node scripts/check-export-pages.mjs", "check:static-auth": "node scripts/check-static-auth.mjs", + "check:tutor-logic": "node scripts/check-tutor-logic.mjs", "test:static": "node scripts/check-static-auth.mjs" }, "dependencies": { diff --git a/site-astro/scripts/check-export-pages.mjs b/site-astro/scripts/check-export-pages.mjs index 81aba82..fde698f 100644 --- a/site-astro/scripts/check-export-pages.mjs +++ b/site-astro/scripts/check-export-pages.mjs @@ -120,7 +120,13 @@ function listDirs(dir) { .map((entry) => entry.name); } -const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro"]); +// "terms"/"privacy" are t1's versioned policy pages (src/pages/terms/, +// src/pages/privacy/) — real top-level routes with no backing entry in the +// content-export, since they aren't subject content. "consent" is t10's +// pending-consent notice (src/pages/consent/) — same reasoning: a real +// top-level route, not subject content. "voice" is t16's voice-session page +// (src/pages/voice/) — likewise a real route, not subject content. +const KNOWN_NON_SUBJECT_DIRS = new Set(["_astro", "terms", "privacy", "consent", "voice"]); for (const dirName of listDirs(distDir)) { if (KNOWN_NON_SUBJECT_DIRS.has(dirName)) continue; if (!subjectNames.has(dirName)) { diff --git a/site-astro/scripts/check-static-auth.mjs b/site-astro/scripts/check-static-auth.mjs index c6772f8..25c1199 100644 --- a/site-astro/scripts/check-static-auth.mjs +++ b/site-astro/scripts/check-static-auth.mjs @@ -165,12 +165,25 @@ check("global.css hides .signedin-only by an unconditional display:none", () => }); check("the landing page and every subject page render the signed-out invitation in raw HTML", () => { - const mustContain = [distDir /* landing */]; const missing = []; const landing = readFileSync(path.join(distDir, "index.html"), "utf8"); if (!landing.includes("Sign in to track progress")) missing.push("dist/index.html"); + // t1's versioned policy pages (src/pages/terms/, src/pages/privacy/) are + // plain static prose with no learner panel — they carry no + // signed-in/signed-out split at all, so this check (which is about that + // split, not "every top-level page") doesn't apply to them. t10's consent + // notice (src/pages/consent/) is the same shape one level further: it has + // its OWN pending/expired/already-in/declined/error states (driven by + // src/scripts/consent.js, checked separately below), not the site-wide + // signed-out/signed-in split this check is about. + // t16's voice page (src/pages/voice/) is the same shape as consent: its + // OWN gate states (signed-out/consent-needed/not-approved/ready/error, + // driven by src/scripts/voice.js and checked separately below), not the + // site-wide learner-panel split this check is about — its signed-out state + // is a sign-in invitation with its own copy. + const NOT_A_LEARNER_PANEL_PAGE = new Set(["_astro", "terms", "privacy", "consent", "voice"]); for (const entry of readdirSync(distDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name === "_astro") continue; + if (!entry.isDirectory() || NOT_A_LEARNER_PANEL_PAGE.has(entry.name)) continue; const subjectIndex = path.join(distDir, entry.name, "index.html"); if (!existsSync(subjectIndex)) continue; const html = readFileSync(subjectIndex, "utf8"); @@ -199,7 +212,45 @@ check("story pages keep the signed-out CTA note visible-by-default (no hidden at const learnerJsPath = path.join(srcDir, "scripts", "learner.js"); const learnerJs = readFileSync(learnerJsPath, "utf8"); -const ALLOWED_SUFFIX_RE = /^(\/me|\/progress\/|\/record|\/auth\/)/; +// t8 (roles + visibility) adds /export, /delete (the export/delete web +// affordance the consent page's own copy promised, deferred from t7), and +// the admin surface (gated client-side on the `is_admin` field GET /api/me +// now carries — the server enforces the allow-list independently either +// way). /me/visibility is already covered by the existing `\/me` prefix +// (unanchored at the end, by design — see the two call sites below). +// t9 (approval gate) enumerates the admin routes EXACTLY — /admin/learners, +// /admin/approve, /admin/revoke, each anchored — replacing t8's open +// `/admin/` prefix, so a future admin route must be added here consciously +// rather than riding in under the prefix. learner.js keeps approve/revoke +// as two static templates (never `/admin/${verb}`) for exactly this reason. +const ALLOWED_SUFFIX_RE = + /^(\/me|\/progress\/|\/record|\/auth\/|\/export|\/delete|\/admin\/(learners|approve|revoke)$)/; + +// t10's consent.js is a SECOND, separately-loaded script (only on +// src/pages/consent/index.astro, not via Layout.astro) with its own, +// narrower fetch surface — enumerated exactly, not merged into the +// learner.js whitelist above, so a future edit that widens one script can't +// silently widen the other. Used below for consent.js's own source check, +// and folded into the built-bundle whitelist further down (that check scans +// every built .js file regardless of which source script produced it). +const CONSENT_ALLOWED_SUFFIX_RE = /^(\/me|\/consent$|\/consent\/accept$|\/consent\/decline$)/; + +// t16's voice.js is a THIRD separately-loaded script (only on +// src/pages/voice/index.astro), with the narrowest fetch surface of all: +// the /api/me bootstrap plus the voice-token mint, enumerated exactly. The +// wss:// bridge connection it then opens is a WebSocket, not a fetch() — +// its own narrow allowance is checked in section 5 below (exactly ONE +// `new WebSocket(` in the file, reachable only AFTER the mint succeeded). +const VOICE_ALLOWED_SUFFIX_RE = /^(\/me$|\/voice\/token$)/; + +// t15's tutor.js is a FOURTH separately-loaded script (only via +// TutorPanel.astro's own import, never Layout.astro) with its own exact +// whitelist: the model-spending broker route (anchored — no sub-routes ride +// in under it), the per-subject progress read that feeds next-step/cloze +// personalization, and the existing record route the played story's tally +// goes to. No /me (learner.js publishes its already-fetched payload — see +// the learn:me hook in bootstrap()), no /auth, no admin/export/delete. +const TUTOR_ALLOWED_SUFFIX_RE = /^(\/tutor$|\/progress\/|\/record$)/; check("learner.js imports the single API_BASE constant (no hardcoded alternate host)", () => { assert.match(learnerJs, /import\s*\{\s*API_BASE\s*\}\s*from\s*["']\.\.\/lib\/api\.js["']/); @@ -220,14 +271,30 @@ check("every fetch() call in learner.js targets ${API_BASE} plus a whitelisted s assert.deepEqual(offenders, [], `non-whitelisted fetch target(s): ${offenders.join(", ")}`); }); -check("no reference to the model-spending /tutor route anywhere in site-astro (src or built)", () => { - // Deliberately searches for the bare "/tutor" suffix, not "/api/tutor": in - // both source (`${API_BASE}/tutor`) and the minified build (`${e}/tutor`), - // the "/api" prefix only ever exists inside the API_BASE variable, never - // concatenated into a literal string — so "/tutor" is the substring that - // would actually survive minification if this route were ever wired up, - // and is specific enough not to false-positive on anything else in this - // codebase. +check("the model-spending /tutor route is referenced ONLY through whitelisted fetch templates", () => { + // t15 retires the pre-t15 blanket rule ("referenced nowhere in this + // site"): the approved-tier tutor surface (src/scripts/tutor.js) is now + // the ONE legitimate caller of POST /api/tutor. This stays precise, not + // loosened — strip exactly the three accounted-for forms, then ANY + // surviving "/tutor" substring (a hardcoded URL, a second call site, a + // string a whitelist check can't see) still fails the build: + // 1. fetch() template literals — every one is independently validated + // against the per-script suffix whitelists (tutor.js's own source + // check below; the built-bundle union check further down), so they + // are accounted for, not ignored; + // 2. quoted module/asset paths that merely NAME the tutor scripts or + // their built chunk ("../scripts/tutor.js", "./tutor-core.js", + // + + diff --git a/site-astro/src/content-export/stories-culture-guide.json b/site-astro/src/content-export/stories-culture-guide.json index e410782..876a40b 100644 --- a/site-astro/src/content-export/stories-culture-guide.json +++ b/site-astro/src/content-export/stories-culture-guide.json @@ -67,6 +67,59 @@ "agent-fleet" ], "type": "multiple_choice" + }, + { + "blanks": [ + { + "answer": "system prompt", + "id": "leak_source", + "options": [ + "system prompt", + "secret manager", + "changelog" + ] + }, + { + "answer": "secret manager", + "id": "fix", + "options": [ + "secret manager", + "public door", + "debug log" + ] + } + ], + "id": "cg.story.cg-scenario-before-the-public-door.6", + "item_id": "secrets", + "prompt": "Fill in each blank with the term that best completes the postmortem.", + "related_concepts": [ + "one-or-many", + "public-access" + ], + "text": "The billing key had been pasted straight into the agent's {{leak_source}}; federation carried that context onto a machine with a debug log that captured it in cleartext. The team's fix was to pull every credential out of prompts and repos and place it behind a {{fix}}, so each agent held only a scoped, rotatable secret.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "Level 3 agent-management tier", + "id": "tier", + "options": [ + "Level 3 agent-management tier", + "Level 1 project agent", + "Level 4 orchestrator" + ] + } + ], + "id": "cg.story.cg-scenario-before-the-public-door.7", + "item_id": "agent-levels", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "agent-fleet", + "company-scale" + ], + "text": "Six agents and no one whose job was to manage agents — the fleet was missing its {{tier}}, the tier that becomes worth adding once a fleet passes roughly three-to-five agents, owning onboarding, skills, and credential rotation across the fleet.", + "type": "cloze" } ], "glossary": [ @@ -169,6 +222,50 @@ ], "rubric": "Passes if the learner names a concrete question, a specific grounding source the agent can actually reach, and an explicit 'say you do not know / cite the source' boundary. Partial if any of the three is missing or left vague.", "type": "open" + }, + { + "blanks": [ + { + "answer": "hallucination", + "id": "term", + "options": [ + "hallucination", + "deception", + "refusal" + ] + } + ], + "id": "cg.story.cg-scenario-the-confident-intern.5", + "item_id": "hallucinations", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "prediction", + "prompt-engineering" + ], + "text": "With nothing in its context grounding the real client flags, the model produced a fluent, confident-sounding answer that simply wasn't true — not a lie, but a {{term}}: the default failure mode whenever an answer is ungrounded.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "system prompt", + "id": "term", + "options": [ + "system prompt", + "changelog", + "error log" + ] + } + ], + "id": "cg.story.cg-scenario-the-confident-intern.6", + "item_id": "system-prompt", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "prompt-engineering", + "hallucinations" + ], + "text": "Ravi stopped treating the {{term}} as a place to scold the model and started treating it as a program — its phrasing and examples were what actually steered which answers became likely.", + "type": "cloze" } ], "glossary": [ @@ -261,6 +358,50 @@ ], "rubric": "Passes if the learner sees that 'forgetting under overload' looks human but the mechanism is finite context and attention dilution (not tiredness), notes that the agent will not push back or flag its own confusion, and ties the fix to context engineering rather than persuasion. Partial if it labels the behaviours without connecting them to the fix.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "definitions", + "id": "term", + "options": [ + "definitions", + "credentials", + "transcripts" + ] + } + ], + "id": "cg.story.cg-scenario-the-over-tooled-agent.5", + "item_id": "mcps", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "tool-use", + "limitations" + ], + "text": "Every MCP the agent connected to injected its tool {{term}} — names, descriptions, parameter schemas — into the context before the agent did anything at all; five broad servers filled a large fraction of the window with these alone.", + "type": "cloze" + }, + { + "blanks": [ + { + "answer": "context engineering", + "id": "term", + "options": [ + "context engineering", + "prompt engineering", + "fine-tuning" + ] + } + ], + "id": "cg.story.cg-scenario-the-over-tooled-agent.6", + "item_id": "context-engineering", + "prompt": "Fill in the blank with the term that best completes the sentence.", + "related_concepts": [ + "mcps", + "agent-definition" + ], + "text": "The fix was not a bigger model or a longer prompt — it was {{term}}: deciding what belongs in the context window for this specific job, then cutting the agent's default MCPs down to two, tightly scoped.", + "type": "cloze" } ], "glossary": [ diff --git a/site-astro/src/content-export/stories-french.json b/site-astro/src/content-export/stories-french.json index 58958e6..8f346f7 100644 --- a/site-astro/src/content-export/stories-french.json +++ b/site-astro/src/content-export/stories-french.json @@ -41,6 +41,25 @@ "prompt": "Pourquoi Nina a-t-elle un peu peur le matin de la rentrée ?", "rubric": "Passes if the answer mentions the new teacher or general first-day nerves; partial if it just says 'she is scared' with no reason.", "type": "open" + }, + { + "answer": "maîtresse", + "blanks": [ + { + "answer": "maîtresse", + "id": "enseignante", + "options": [ + "maîtresse", + "maître", + "professeur" + ] + } + ], + "id": "fr-a1-la-rentree-q5", + "item_id": "fr.story.fr-a1-la-rentree.2", + "prompt": "Fill in each blank with the right word.", + "text": "« Il y a une nouvelle {{enseignante}} cette année, » dit Nina.", + "type": "cloze" } ], "glossary": [ @@ -130,6 +149,25 @@ "prompt": "Pourquoi Léo est-il fier à la fin de l'histoire ?", "rubric": "Passes if the answer names that he spoke French with the vendors by himself/alone; partial if it only says he went to the market.", "type": "open" + }, + { + "answer": "kilo", + "blanks": [ + { + "answer": "kilo", + "id": "unit", + "options": [ + "kilo", + "litre", + "mètre" + ] + } + ], + "id": "fr-a1-le-marche-q5", + "item_id": "fr.story.fr-a1-le-marche.3", + "prompt": "Fill in each blank with the right word.", + "text": "Léo dit au vendeur : « Bonjour, monsieur. Je voudrais un {{unit}} de carottes, s'il vous plaît. »", + "type": "cloze" } ], "glossary": [ @@ -221,6 +259,25 @@ "prompt": "Amina dit que retrouver une vieille amie lui a fait du bien. Raconte une fois où tu as retrouvé un ami après une longue absence.", "rubric": "Passes if the learner produces a coherent short personal narrative in French connected to the theme of reunion; partial if the answer is a single word or off-topic.", "type": "discussion" + }, + { + "answer": "menthe", + "blanks": [ + { + "answer": "menthe", + "id": "gout", + "options": [ + "menthe", + "citron", + "vanille" + ] + } + ], + "id": "fr-a2-le-cafe-du-coin-q5", + "item_id": "fr.story.fr-a2-le-cafe-du-coin.2", + "prompt": "Fill in each blank with the right word.", + "text": "Chloé choisit un thé à la {{gout}}.", + "type": "cloze" } ], "glossary": [ @@ -313,6 +370,25 @@ "prompt": "Décris ce que la famille fait le soir dans le chalet.", "rubric": "Passes if the answer mentions warming up by the fireplace, the father telling stories, and the mother making hot chocolate; partial if only one detail is given.", "type": "open" + }, + { + "answer": "bonhomme", + "blanks": [ + { + "answer": "bonhomme", + "id": "construction", + "options": [ + "bonhomme", + "château", + "bateau" + ] + } + ], + "id": "fr-a2-le-weekend-a-la-montagne-q5", + "item_id": "fr.story.fr-a2-le-weekend-a-la-montagne.1", + "prompt": "Fill in each blank with the right word.", + "text": "Ils sortent et construisent un grand {{construction}} de neige avec une carotte pour le nez.", + "type": "cloze" } ], "glossary": [ @@ -405,6 +481,33 @@ "prompt": "Que ferais-tu si on te posait une question à laquelle tu ne t'attends pas pendant un entretien ?", "rubric": "Passes if the learner gives a coherent personal strategy in French (e.g. pausing to think, asking for clarification, giving a concrete example); partial if the answer is a single word.", "type": "open" + }, + { + "blanks": [ + { + "answer": "retenus", + "id": "participe", + "options": [ + "retenus", + "retenues", + "retenu" + ] + }, + { + "answer": "peine", + "id": "mot", + "options": [ + "peine", + "douleur", + "peur" + ] + } + ], + "id": "fr-b1-entretien-embauche-q5", + "item_id": "fr.story.fr-b1-entretien-embauche.3", + "prompt": "Fill in each blank with the right word.", + "text": "L'entreprise contactera les candidats {{participe}} dans les deux semaines. Toute cette nervosité, finalement, en valait la {{mot}}.", + "type": "cloze" } ], "glossary": [ @@ -505,6 +608,33 @@ "prompt": "Selon le texte, pourquoi la fête des voisins est-elle importante pour une communauté ? Ton quartier organise-t-il un événement similaire ?", "rubric": "Passes if the response connects the event to building community between strangers and offers at least a brief reflection on the learner's own context; partial if it only summarizes the story.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "détend", + "id": "verbe", + "options": [ + "détend", + "détendent", + "détends" + ] + }, + { + "answer": "à peine", + "id": "adverbe", + "options": [ + "à peine", + "déjà", + "toujours" + ] + } + ], + "id": "fr-b1-la-fete-des-voisins-q5", + "item_id": "fr.story.fr-b1-la-fete-des-voisins.4", + "prompt": "Fill in each blank with the right word.", + "text": "Après quelques verres de vin et beaucoup de rires, l'ambiance se {{verbe}}. Avant cette soirée, elle connaissait {{adverbe}} le nom de ses voisins.", + "type": "cloze" } ], "glossary": [ @@ -606,6 +736,33 @@ "prompt": "Camille hésite entre l'espace et la proximité du travail. Si tu devais choisir, que privilégierais-tu, et pourquoi ?", "rubric": "Passes if the learner gives a reasoned personal preference in French that engages with the space-vs-commute trade-off; partial if it only picks one side without a reason.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "charges", + "id": "mot", + "options": [ + "charges", + "frais", + "taxes" + ] + }, + { + "answer": "spacieux", + "id": "adj", + "options": [ + "spacieux", + "spacieuse", + "spacieuses" + ] + } + ], + "id": "fr-b1-la-recherche-appartement-q5", + "item_id": "fr.story.fr-b1-la-recherche-appartement.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le propriétaire explique que le loyer comprend les {{mot}}, mais pas internet. Bien qu'il soit {{adj}} et lumineux, le trajet jusqu'au travail prendrait presque quarante minutes.", + "type": "cloze" } ], "glossary": [ @@ -706,6 +863,33 @@ "prompt": "Selon Julien, qu'est-ce que le covoiturage lui a apporté en plus des économies d'essence ?", "rubric": "Passes if the answer names new friendships / social connection with the other passengers; partial if it only restates the fuel savings.", "type": "open" + }, + { + "blanks": [ + { + "answer": "panne", + "id": "mot", + "options": [ + "panne", + "accident", + "retard" + ] + }, + { + "answer": "dépanneuse", + "id": "mot2", + "options": [ + "dépanneuse", + "ambulance", + "police" + ] + } + ], + "id": "fr-b1-le-covoiturage-q5", + "item_id": "fr.story.fr-b1-le-covoiturage.2", + "prompt": "Fill in each blank with the right word.", + "text": "Un matin, la voiture tombe en {{mot}} sur l'autoroute. Ines appelle une {{mot2}} pendant que Marc plaisante pour détendre l'atmosphère.", + "type": "cloze" } ], "glossary": [ @@ -806,6 +990,33 @@ "prompt": "L'article insiste sur l'absence de « discours idéologique » dans la démarche du village. Penses-tu que ce soit une force ou une limite pour la transition écologique en général ?", "rubric": "Passes if the learner engages with the tension between pragmatic local action and broader ideological/political framing, and takes a defensible position; partial if it only summarizes the village's approach without answering the question.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "sceptique", + "id": "attitude", + "options": [ + "sceptique", + "optimiste", + "confiant" + ] + }, + { + "answer": "dépassé", + "id": "verbe", + "options": [ + "dépassé", + "respecté", + "déçu" + ] + } + ], + "id": "fr-b2-la-transition-ecologique-q5", + "item_id": "fr.story.fr-b2-la-transition-ecologique.2", + "prompt": "Fill in each blank with the right word.", + "text": "Le maire, initialement {{attitude}} quant à la rentabilité du projet, admet aujourd'hui que les économies réalisées sur la facture d'électricité ont {{verbe}} ses attentes les plus optimistes.", + "type": "cloze" } ], "glossary": [ @@ -910,6 +1121,33 @@ "prompt": "L'auteur termine par une question sur une remise en cause plus vaste de l'organisation du travail salarié. Qu'en penses-tu : le télétravail est-il une simple adaptation, ou le début d'un changement plus profond ?", "rubric": "Passes if the learner takes and defends a position with at least one supporting reason, engaging with the article's closing question; partial if it restates the question without taking a position.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "affaiblisse", + "id": "verbe", + "options": [ + "affaiblisse", + "affaiblit", + "affaiblira" + ] + }, + { + "answer": "s'efface", + "id": "verbe2", + "options": [ + "s'efface", + "s'effacent", + "s'effaçait" + ] + } + ], + "id": "fr-b2-le-teletravail-q5", + "item_id": "fr.story.fr-b2-le-teletravail.2", + "prompt": "Fill in each blank with the right word.", + "text": "Plusieurs managers s'inquiètent que le télétravail n'{{verbe}} la cohésion des équipes, et que la frontière entre vie privée et vie professionnelle ne {{verbe2}} complètement.", + "type": "cloze" } ], "glossary": [ @@ -1014,6 +1252,33 @@ "prompt": "Le texte ne révèle jamais ce que contenait le livre offert par Monsieur Aubert. Pourquoi, selon toi, l'auteur fait-il ce choix ? Qu'est-ce que cela ajoute à l'histoire ?", "rubric": "Passes if the learner reflects on the literary effect of withholding the title (mystery, focus on the human moment rather than the object, room for the reader's imagination) rather than simply guessing a specific title; partial if the answer only guesses a title without reflecting on the choice.", "type": "discussion" + }, + { + "blanks": [ + { + "answer": "deviner", + "id": "verbe", + "options": [ + "deviner", + "décider", + "devenir" + ] + }, + { + "answer": "cherchait", + "id": "verbe2", + "options": [ + "cherchait", + "demandait", + "voulait" + ] + } + ], + "id": "fr-c1-le-vieux-libraire-q5", + "item_id": "fr.story.fr-c1-le-vieux-libraire.2", + "prompt": "Fill in each blank with the right word.", + "text": "Il avait ce don, rare, de {{verbe}} en quelques mots à peine échangés ce qu'une personne {{verbe2}} vraiment à lire.", + "type": "cloze" } ], "glossary": [ diff --git a/site-astro/src/content-export/stories-spanish.json b/site-astro/src/content-export/stories-spanish.json index 8a81e31..a9f9a6c 100644 --- a/site-astro/src/content-export/stories-spanish.json +++ b/site-astro/src/content-export/stories-spanish.json @@ -40,6 +40,33 @@ "prompt": "¿Por qué le gustan los plátanos a Ana?", "rubric": "Passes if the answer mentions that they are sweet and easy to eat (son dulces y fáciles de comer); naming only one of the two reasons is partial.", "type": "short_answer" + }, + { + "blanks": [ + { + "answer": "rojas", + "id": "adj1", + "options": [ + "rojas", + "rojo", + "rojos" + ] + }, + { + "answer": "amarillos", + "id": "adj2", + "options": [ + "amarillos", + "amarilla", + "amarillas" + ] + } + ], + "id": "es-a1-el-mercado-de-frutas-q5", + "item_id": "es.story.es-a1-el-mercado-de-frutas.1", + "prompt": "Completa cada espacio con la palabra correcta (concordancia de género y número).", + "text": "En el mercado hay frutas de muchos colores: manzanas {{adj1}} y plátanos {{adj2}}.", + "type": "cloze" } ], "glossary": [ @@ -128,6 +155,25 @@ "prompt": "Describe a Rex, el perro de la familia.", "rubric": "Passes if the answer mentions his colors (negro y blanco) and that he likes to run in the garden; naming only the colors is partial.", "type": "short_answer" + }, + { + "answer": "es", + "blanks": [ + { + "answer": "es", + "id": "ser_estar", + "options": [ + "es", + "está", + "son" + ] + } + ], + "id": "es-a1-mi-familia-q5", + "item_id": "es.story.es-a1-mi-familia.1", + "prompt": "Completa el espacio con la palabra correcta (ser vs. estar).", + "text": "Mi papá {{ser_estar}} alto y trabaja en un hospital.", + "type": "cloze" } ], "glossary": [ @@ -211,6 +257,25 @@ "prompt": "¿Cuántas personas van a venir a la fiesta y quiénes son?", "rubric": "Passes if the answer says twelve people, including classmates and cousins (compañeros de clase y primos); naming only the number is partial.", "type": "short_answer" + }, + { + "answer": "va", + "blanks": [ + { + "answer": "va", + "id": "verbo", + "options": [ + "va", + "van", + "vamos" + ] + } + ], + "id": "es-a2-el-cumpleanos-de-lucia-q5", + "item_id": "es.story.es-a2-el-cumpleanos-de-lucia.1", + "prompt": "Completa el espacio con la forma correcta de 'ir a + infinitivo'.", + "text": "Tomás {{verbo}} a decorar el jardín con globos de colores.", + "type": "cloze" } ], "glossary": [ @@ -298,6 +363,25 @@ "prompt": "¿Qué hicieron los amigos que no nadaron?", "rubric": "Passes if the answer mentions they played beach volleyball on the sand (jugaron vóley playa en la arena).", "type": "short_answer" + }, + { + "answer": "fueron", + "blanks": [ + { + "answer": "fueron", + "id": "verbo", + "options": [ + "fueron", + "fue", + "van" + ] + } + ], + "id": "es-a2-un-dia-en-la-playa-q5", + "item_id": "es.story.es-a2-un-dia-en-la-playa.1", + "prompt": "Completa el espacio con la forma correcta del pretérito de 'ir'.", + "text": "Camila y sus amigos {{verbo}} a la playa por primera vez del verano.", + "type": "cloze" } ], "glossary": [ @@ -385,6 +469,25 @@ "prompt": "¿Qué tres lugares descubrió Diego en el barrio nuevo?", "rubric": "Passes if the answer names the bakery, the basketball court, and the library (la panadería, la cancha de básquetbol, la biblioteca); two of three is partial.", "type": "short_answer" + }, + { + "answer": "para", + "blanks": [ + { + "answer": "para", + "id": "prep", + "options": [ + "para", + "por", + "porque" + ] + } + ], + "id": "es-b1-el-barrio-nuevo-q5", + "item_id": "es.story.es-b1-el-barrio-nuevo.1", + "prompt": "Completa el espacio con la preposición correcta (por vs. para).", + "text": "Diego decidió aceptar la invitación {{prep}} conocer a los chicos del barrio nuevo.", + "type": "cloze" } ], "glossary": [ @@ -487,6 +590,25 @@ "item_id": "es.story.es-b1-el-tren-perdido.5", "prompt": "Traduce al español: \"I realized I only had twenty minutes.\"", "type": "translation" + }, + { + "answer": "llegó", + "blanks": [ + { + "answer": "llegó", + "id": "verbo", + "options": [ + "llegó", + "llegaba", + "llega" + ] + } + ], + "id": "es-b1-el-tren-perdido-q6", + "item_id": "es.story.es-b1-el-tren-perdido.1", + "prompt": "Completa el espacio con la forma correcta (pretérito vs. imperfecto).", + "text": "Cuando por fin Martín {{verbo}} al andén, el tren ya estaba cerrando las puertas.", + "type": "cloze" } ], "glossary": [ @@ -589,6 +711,25 @@ "prompt": "¿Qué habrías respondido tú si te hubieran hecho la misma pregunta sobre un proyecto del que te sientas orgulloso/a?", "rubric": "Open-ended: passes if the learner describes a concrete project and connects it to a skill, in Spanish, using at least one past tense correctly.", "type": "discussion" + }, + { + "answer": "estaba", + "blanks": [ + { + "answer": "estaba", + "id": "verbo", + "options": [ + "estaba", + "era", + "fue" + ] + } + ], + "id": "es-b1-la-entrevista-de-trabajo-q6", + "item_id": "es.story.es-b1-la-entrevista-de-trabajo.1", + "prompt": "Completa el espacio con la forma correcta (ser vs. estar).", + "text": "Antes de la entrevista, Valentina {{verbo}} un poco nerviosa en la sala de espera.", + "type": "cloze" } ], "glossary": [ @@ -684,6 +825,25 @@ "prompt": "¿Qué noticia sobre la salud de la abuela le contó su hermano?", "rubric": "Passes if the answer mentions the grandmother went to the doctor and everything was fine (fue al médico y todo salió bien).", "type": "short_answer" + }, + { + "answer": "me", + "blanks": [ + { + "answer": "me", + "id": "pron", + "options": [ + "me", + "te", + "le" + ] + } + ], + "id": "es-b1-una-carta-a-mi-abuela-q5", + "item_id": "es.story.es-b1-una-carta-a-mi-abuela.1", + "prompt": "Completa el espacio con el pronombre de objeto indirecto correcto.", + "text": "Olvido las cosas simples que tú siempre {{pron}} enseñaste, abuela.", + "type": "cloze" } ], "glossary": [ @@ -786,6 +946,25 @@ "prompt": "¿Estás de acuerdo con Beatriz en que es difícil construir relaciones profesionales sin verse en persona? Explica tu opinión usando el subjuntivo al menos una vez (por ejemplo, con \"dudo que\" o \"no creo que\").", "rubric": "Open-ended: passes if the learner states a clear opinion and correctly uses a subjunctive trigger phrase; a plausible opinion without the subjunctive is partial.", "type": "discussion" + }, + { + "answer": "sea", + "blanks": [ + { + "answer": "sea", + "id": "verbo", + "options": [ + "sea", + "es", + "será" + ] + } + ], + "id": "es-b2-el-dilema-del-teletrabajo-q6", + "item_id": "es.story.es-b2-el-dilema-del-teletrabajo.3", + "prompt": "Completa el espacio con la forma correcta del subjuntivo (no creo que + subjuntivo).", + "text": "No creo que la oficina {{verbo}} la única solución al problema de Renata.", + "type": "cloze" } ], "glossary": [ @@ -897,6 +1076,25 @@ "prompt": "El texto usa vocabulario y platos regionales de México (las posadas, los romeritos, la cuadra). ¿Qué tradición navideña de tu propia cultura podrías comparar con las posadas?", "rubric": "Open-ended: passes if the learner names a comparable tradition and draws at least one concrete point of comparison, in Spanish.", "type": "discussion" + }, + { + "answer": "recorría", + "blanks": [ + { + "answer": "recorría", + "id": "verbo", + "options": [ + "recorría", + "recorrió", + "recorre" + ] + } + ], + "id": "es-b2-las-fiestas-de-mi-pueblo-q6", + "item_id": "es.story.es-b2-las-fiestas-de-mi-pueblo.1", + "prompt": "Completa el espacio con la forma correcta (pretérito vs. imperfecto para acciones habituales).", + "text": "Cada noche, un grupo de vecinos {{verbo}} las calles cantando villancicos.", + "type": "cloze" } ], "glossary": [ @@ -1014,6 +1212,25 @@ "prompt": "El cuento usa voseo rioplatense (\"vos sos\", \"encontrás\", \"dejate\") en vez de \"tú eres\", \"encuentras\", \"déjate\". Reescribe la frase de Renata \"Dejate sorprender por la que encontrás ahora\" usando la forma de tú.", "rubric": "Passes if the learner correctly converts both verbs: 'Déjate sorprender por la que encuentras ahora.'", "type": "discussion" + }, + { + "answer": "sos", + "blanks": [ + { + "answer": "sos", + "id": "verbo", + "options": [ + "sos", + "eres", + "sois" + ] + } + ], + "id": "es-c1-el-ultimo-tren-a-buenos-aires-q6", + "item_id": "es.story.es-c1-el-ultimo-tren-a-buenos-aires.5", + "prompt": "Completa el espacio con la forma correcta de 'ser' en voseo rioplatense.", + "text": "—¿Vos {{verbo}} de acá? —le preguntó la mujer a Julián.", + "type": "cloze" } ], "glossary": [ diff --git a/site-astro/src/lib/content.ts b/site-astro/src/lib/content.ts index 62ab703..c90baea 100644 --- a/site-astro/src/lib/content.ts +++ b/site-astro/src/lib/content.ts @@ -36,6 +36,15 @@ export interface GlossaryEntry { note?: string; } +// A pick-the-right-word cloze blank: the reader picks `answer` out of +// `options` (distractors + the correct word). See docs/specs/ +// subject-plugin-contract.md §3.6.1 (learn-cli's t3 uplift). +export interface ClozeBlank { + id: string; + options: string[]; + answer: string; +} + export interface Exercise { id: string; type: string; @@ -44,6 +53,12 @@ export interface Exercise { choices?: string[]; answer?: string; rubric?: string; + // Pick-the-right-word cloze variant ONLY (§3.6.1): the passage with each + // blank marked `{{blank_id}}`, plus one `blanks[]` entry per placeholder. + // Absent for the legacy single-blank cloze form (prompt + answer only, + // unchanged since contract 1.0) and for every non-cloze exercise type. + text?: string; + blanks?: ClozeBlank[]; } export interface Story { diff --git a/site-astro/src/lib/terms.ts b/site-astro/src/lib/terms.ts new file mode 100644 index 0000000..040d5c4 --- /dev/null +++ b/site-astro/src/lib/terms.ts @@ -0,0 +1,10 @@ +// Site-side re-export of the shared Terms/Privacy version, mirroring +// ../lib/content.ts's role as the typed loader every page imports through. +// Both policy pages (src/pages/terms/, src/pages/privacy/) import from this +// local path rather than reaching across the repo boundary themselves — the +// fact that the value actually lives in ../../../shared/terms-version.mjs is +// an implementation detail of this one file. +// +// See ../../../shared/terms-version.mjs for why that file is a plain ES +// module (not JSON) and for the re-consent contract TERMS_VERSION backs. +export { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../../shared/terms-version.mjs"; diff --git a/site-astro/src/pages/[subject]/index.astro b/site-astro/src/pages/[subject]/index.astro index fe2cbc6..0046418 100644 --- a/site-astro/src/pages/[subject]/index.astro +++ b/site-astro/src/pages/[subject]/index.astro @@ -6,6 +6,7 @@ import Layout from "../../layouts/Layout.astro"; import PageHero from "../../components/PageHero.astro"; import LearnerPanelSubject from "../../components/LearnerPanelSubject.astro"; +import TutorPanel from "../../components/TutorPanel.astro"; import site from "../../data/site"; import { getSubjects, getStories, levelRank, type Subject, type Story } from "../../lib/content"; @@ -59,6 +60,7 @@ if (!subject.available) {
+
diff --git a/site-astro/src/pages/[subject]/stories/[id]/index.astro b/site-astro/src/pages/[subject]/stories/[id]/index.astro index 1dfb5ac..e628ede 100644 --- a/site-astro/src/pages/[subject]/stories/[id]/index.astro +++ b/site-astro/src/pages/[subject]/stories/[id]/index.astro @@ -1,12 +1,23 @@ --- // The story reader: clean prose, glossary, and comprehension questions -// listed statically (no interactive form — practice/checking is CLI or -// signed-in territory, a later wave). One page per story in the -// content-export; scripts/check-export-pages.mjs enforces the pairing. +// listed statically. Most exercise types render as plain text (no form — +// practice/checking is CLI or signed-in territory, a later wave); the +// pick-the-right-word cloze variant (§3.6.1) is the one exception: a +// dependency-free, sign-in-independent picker wired by +// src/scripts/learner.js's wireClozeExercises(), giving instant right/wrong +// feedback entirely client-side (no fetch — the answer is already public in +// this page's own data). One page per story in the content-export; +// scripts/check-export-pages.mjs enforces the pairing. import Layout from "../../../../layouts/Layout.astro"; import PageHero from "../../../../components/PageHero.astro"; import site from "../../../../data/site"; -import { getSubjects, getStories, type Subject, type Story } from "../../../../lib/content"; +import { + getSubjects, + getStories, + type Subject, + type Story, + type ClozeBlank, +} from "../../../../lib/content"; export function getStaticPaths() { return getSubjects().flatMap((subject) => @@ -24,6 +35,38 @@ interface Props { const { subject, story } = Astro.props; +// Split a cloze exercise's `text` on `{{blank_id}}` placeholders into an +// ordered list of plain-text runs and blank widgets, for the pick-the- +// right-word variant (§3.6.1). A placeholder with no matching `blanks` entry +// renders as literal text rather than breaking the build — malformed content +// is `learn subject doctor`'s `cloze-items` check's job to catch upstream, +// not this page's. +type ClozeSegment = { kind: "text"; value: string } | { kind: "blank"; blank: ClozeBlank }; + +function clozeSegments(text: string, blanks: ClozeBlank[]): ClozeSegment[] { + const byId = new Map(blanks.map((b) => [b.id, b])); + const segments: ClozeSegment[] = []; + const placeholderRe = /\{\{([^{}]+)\}\}/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = placeholderRe.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ kind: "text", value: text.slice(lastIndex, match.index) }); + } + const blank = byId.get(match[1]); + if (blank) { + segments.push({ kind: "blank", blank }); + } else { + segments.push({ kind: "text", value: match[0] }); + } + lastIndex = placeholderRe.lastIndex; + } + if (lastIndex < text.length) { + segments.push({ kind: "text", value: text.slice(lastIndex) }); + } + return segments; +} + // Paragraphs are separated by a blank line; a lone \n inside a paragraph is // just a wrapped source line, not an intentional break, so it collapses to // a space rather than a
. @@ -121,13 +164,39 @@ const typeLabel: Record = { {typeLabel[exercise.type] ?? exercise.type} -

{exercise.prompt}

- {exercise.choices && exercise.choices.length > 0 && ( -
    - {exercise.choices.map((choice) => ( -
  • {choice}
  • - ))} -
+ {exercise.type === "cloze" && exercise.blanks && exercise.blanks.length > 0 ? ( +
+

{exercise.prompt}

+

+ {clozeSegments(exercise.text ?? "", exercise.blanks).map((segment) => + segment.kind === "text" ? ( + segment.value + ) : ( + + + {segment.blank.options.map((option) => ( + + ))} + + + + ) + )} +

+
+ ) : ( + <> +

{exercise.prompt}

+ {exercise.choices && exercise.choices.length > 0 && ( +
    + {exercise.choices.map((choice) => ( +
  • {choice}
  • + ))} +
+ )} + )} {exercise.item_id && (
= { background: color-mix(in srgb, var(--mesh-node) 55%, var(--bg)); } + .exercises-section { + /* "Not quite" state: a burnt-orange/coral warm counterpoint to the + aurora-teal accent, tuned per theme (>=4.5:1 against --surface/--bg + in both — see the dataviz-adjacent verification in the t3 PR). */ + --cloze-wrong: #b3401c; + } + + @media (prefers-color-scheme: dark) { + .exercises-section { + --cloze-wrong: #ff9466; + } + } + + .cloze-passage { + font-size: 1rem; + line-height: 2.1; + } + + .cloze-blank { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin: 0 0.15rem; + vertical-align: middle; + } + + .cloze-options { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .cloze-option { + font: inherit; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.2rem 0.75rem; + cursor: pointer; + } + + .cloze-option:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: var(--accent-strong); + } + + .cloze-option:disabled { + cursor: default; + } + + .cloze-option.is-correct { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 22%, var(--surface)); + color: var(--accent-strong); + opacity: 1; + } + + .cloze-option.is-incorrect { + border-color: var(--cloze-wrong); + background: color-mix(in srgb, var(--cloze-wrong) 16%, var(--surface)); + color: var(--cloze-wrong); + opacity: 1; + } + + .cloze-status { + font-size: 0.78rem; + font-style: italic; + } + .cta-note { margin-top: 1.6rem; font-size: 0.94rem; diff --git a/site-astro/src/pages/consent/index.astro b/site-astro/src/pages/consent/index.astro new file mode 100644 index 0000000..06d0e18 --- /dev/null +++ b/site-astro/src/pages/consent/index.astro @@ -0,0 +1,263 @@ +--- +// The consent notice — t10, spec c9/c19 ("the consent UX shape"). An +// unconsented sign-in (web callback) 302s here carrying a short-lived +// pending-consent `session` cookie (10 min TTL); see +// ../../../../workers/learn-api/README.md's "For t10" section for the exact +// contract this page drives (GET /api/me, GET /api/consent, POST +// /api/consent/accept, POST /api/consent/decline). +// +// Same Layout/PageHero shell as every other page here, zero new design +// tokens — the notice content below is static prose (visible with no JS at +// all, like the Terms/Privacy pages), and src/scripts/consent.js only +// toggles which [data-state] block is shown and wires the Accept/Decline +// buttons once it has confirmed a pending session. TERMS_VERSION/ +// TERMS_EFFECTIVE_DATE come from src/lib/terms.ts (the same shared source +// terms/index.astro and privacy/index.astro import) as the build-time +// fallback; consent.js overwrites the [data-field] spans/links with the +// LIVE GET /api/consent response at runtime, which is the authoritative +// value (it's what actually gets recorded on accept). tests/test_consent_page.py +// (repo root) locks this page's key claims and its import-not-hardcode wiring. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { API_BASE } from "../../lib/api"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `Review what ${site.title} stores, why, for how long, and who can see it — nothing is saved until you accept.`; +--- + + + + + + + + + + + + diff --git a/site-astro/src/pages/privacy/index.astro b/site-astro/src/pages/privacy/index.astro new file mode 100644 index 0000000..45223ff --- /dev/null +++ b/site-astro/src/pages/privacy/index.astro @@ -0,0 +1,207 @@ +--- +// Privacy Policy — versioned per spec #11 (plan task t1). Same shell as +// terms/index.astro; see that file's header comment for the shared-version +// wiring (src/lib/terms.ts -> ../../../shared/terms-version.mjs) and the +// cross-check test (tests/test_policy_pages.py at the repo root), which +// asserts this page names all three processors and that its "no email, no +// password" claim actually matches workers/learn-api/schema.sql. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `The Privacy Policy for ${site.orgTitle} ${site.title} (agentculture.org/learn) — what we collect, who we share it with (GitHub, Cloudflare, AWS Bedrock), and your rights.`; +--- + + + + + + +
+
+

+ Version {TERMS_VERSION} + + Effective {TERMS_EFFECTIVE_DATE} +

+

+ Also see the Terms of Use, which covers acceptable use + and the tutoring tier's terms. +

+
+
+ +
+
+

What we collect

+

Signing in and using Learn stores exactly:

+
    +
  • your GitHub numeric id and your public GitHub display + name — the identity GitHub itself hands us during sign-in;
  • +
  • an append-only learning ledger: the activities you complete, + their results, the mastery level they produce, and when each happened, across + every subject you use; and
  • +
  • a session cookie that keeps you signed in between visits.
  • +
+

+ We do not collect or store your email address. GitHub sign-in is + requested with the minimum scope needed to read your public profile; we never + request or read your email. We do not collect or store a password — + there is no Learn-specific password at all; authentication happens entirely + through GitHub's own OAuth flow. +

+ +

Why we collect it

+

We use what's listed above to:

+
    +
  • authenticate you — know it's really you signing back in, without + a password to manage;
  • +
  • track your progress across subjects — one learner, many + subjects, so your French, Spanish, and Culture Guide progress live under one + profile; and
  • +
  • personalize learning — recommend a sensible next step, and, if + you're approved for the tutoring tier, generate exercises and feedback aimed at + what you're actually weak on.
  • +
+ +

Who we share it with

+

+ Three outside services process your data on our behalf, and only these three: +

+
    +
  • + GitHub — the OAuth identity provider. GitHub verifies who you + are during sign-in; Learn never sees or stores anything beyond the numeric id + and display name GitHub returns. +
  • +
  • + Cloudflare — hosting for everything: Workers (the API), KV + (session bookkeeping), D1 (the learner ledger database), and Pages (the static + site you're reading right now). Cloudflare is where your data physically lives. +
  • +
  • + AWS Bedrock — only if an admin has approved you for the + tutoring tier. Bedrock runs Nova Sonic 2 (the voice tutor) and Nova Pro (the + text tutor). If you use the approved tutoring tier, your spoken or + written practice is sent to AWS Bedrock — the model provider — so it can + generate a response. This only ever happens for the approved tier, and + only after you've separately consented to it; if you're not approved, or you + haven't consented, nothing you do reaches Bedrock. +
  • +
+ +

Retention

+

+ Your data is kept for as long as your account exists — there's no separate + expiry timer counting down underneath it. The moment you delete your account + (equivalently, withdraw your consent), everything tied to it is hard-deleted, + in full: your profile row, your entire learning ledger, and the consent + record itself. This is self-serve — you don't need to ask anyone, and it isn't + a soft delete or a grace period; once you confirm it, it's gone. +

+ +

Your rights

+

Whenever you're signed in, you can:

+
    +
  • access what's stored about you — your profile and your full + ledger, on demand;
  • +
  • export a complete copy of your data as JSON, to keep or take + elsewhere;
  • +
  • delete your account and every row tied to it, permanently, in + one action; and
  • +
  • withdraw consent to this policy at any time — which, per the + Retention section above, means the same thing as deleting your account, since + Learn can't keep using data it no longer has your consent to hold.
  • +
+ +

Changes to this policy

+

+ When this policy changes in any way that matters, we publish a new version and + bump the version number at the top of this page. A version bump forces + re-consent: the next time you sign in (or use the API/CLI), you're asked to + accept the new version before anything further is saved. Consent recorded + against one version never silently carries forward to the next. +

+ +

Contact

+

+ For questions, requests, or concerns about this policy, open an issue on + + github.com/agentculture/learn-cli + . We don't publish a personal contact email for privacy requests — use the + issue tracker, or the in-account export/delete actions described above for + anything you can already do yourself. +

+
+
+
+ + diff --git a/site-astro/src/pages/terms/index.astro b/site-astro/src/pages/terms/index.astro new file mode 100644 index 0000000..cc2ad90 --- /dev/null +++ b/site-astro/src/pages/terms/index.astro @@ -0,0 +1,193 @@ +--- +// Terms of Use — versioned per spec #11 (plan task t1). Plain prose page, +// same Layout/PageHero shell as every other page here; no new design +// tokens. TERMS_VERSION/TERMS_EFFECTIVE_DATE come from src/lib/terms.ts (a +// re-export of ../../../shared/terms-version.mjs), the one place both this +// site and workers/learn-api read the published version from — bumping it +// is what forces re-consent (t6). tests/test_policy_pages.py (repo root) +// cross-checks this page's wiring and copy against that shared source and +// against workers/learn-api/schema.sql. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { TERMS_VERSION, TERMS_EFFECTIVE_DATE } from "../../lib/terms"; + +const description = `The Terms of Use for ${site.orgTitle} ${site.title} (agentculture.org/learn) — GitHub-based accounts, acceptable use, and the approved AI-tutoring tier.`; +--- + + + + + + +
+
+

+ Version {TERMS_VERSION} + + Effective {TERMS_EFFECTIVE_DATE} +

+

+ Also see the Privacy Policy, which covers what we + collect and who we share it with. +

+
+
+ +
+
+

1. Accounts

+

+ Learn accounts are GitHub-based only. There is no separate username or + password to create or remember — signing in runs GitHub's own OAuth flow, + and the account it creates here is identified by your GitHub numeric id and + your public GitHub display name. You're responsible for whatever GitHub + account you sign in with. +

+ +

2. Acceptable use

+

Use Learn to learn. In particular, don't:

+
    +
  • try to bypass the consent step, the admin approval gate, or any other access control;
  • +
  • probe, scrape, or overload the site or the API beyond ordinary study use;
  • +
  • submit content to the tutoring tier that you don't have the right to share, or that's + abusive, illegal, or intended to misuse the underlying AI model; or
  • +
  • impersonate another learner or misrepresent your identity beyond what GitHub already + reports.
  • +
+ +

3. The service is provided "as is"

+

+ Learn is an open-source, community-run project. It's provided "as is," without + warranty of any kind — we don't guarantee it will always be available, error-free, + or fit for any particular purpose. Lesson and story content, and anything the + tutoring tier generates, may contain mistakes; verify anything that matters before + you rely on it. +

+ +

4. The approved AI-tutoring tier

+

+ Some learners are approved by an admin for the tutoring tier: a Nova Pro-graded + text tutor and a Nova Sonic 2 voice session, both running on AWS Bedrock. This + tier is opt-in on the admin's side (it costs real money to run) and, separately, + requires your own consent before any of your speech or text reaches the model + provider — see the Privacy Policy for exactly what that + means. Nothing the AI tutor says is guaranteed accurate; it's a study aid, not an + authority, and grading/feedback it gives is a best-effort estimate, not a + certification. +

+ +

5. Your data is yours

+

+ Everything Learn stores about you — your profile row and your learning ledger + (the record of activities, results, and mastery you've built up) — belongs to + you. You can export a full copy or delete all of it, permanently, at any time + from your account settings; deleting your account also withdraws your consent + to these Terms and to the Privacy Policy. See the Privacy Policy for the + mechanics and the retention rule. +

+ +

6. Termination and suspension

+

+ You can stop using Learn and delete your account whenever you like — that's + always available and always immediate. We may suspend or terminate access for + an account that violates Section 2 (acceptable use), including revoking + tutoring-tier approval independently of the underlying account. Where practical + we'll try to give notice first; for clear abuse (for example, hammering the API + or attempting to bypass the approval gate) we may act without warning. +

+ +

7. Changes to these terms

+

+ When these Terms change in any way that matters, we publish a new version and + bump the version number at the top of this page. A version bump means the next + time you sign in (or use the API/CLI) you're asked to re-consent before + anything further is saved — your previous consent covered the version you + accepted, not whatever comes after it. Consent recorded against a given version + is never silently carried forward to the next one. +

+ +

8. Contact and governing law

+

+ Learn is maintained by AgentCulture's agents and human maintainers as an + open-source project, not a registered company — so we're keeping this section + simple rather than pretending otherwise. Questions, disputes, or requests about + these Terms should be opened as an issue on + + github.com/agentculture/learn-cli + — we don't publish a personal contact email for this. Absent a more + specific agreement, these Terms are interpreted under the same open-source, + good-faith norms as the rest of the AgentCulture project. +

+
+
+
+ + diff --git a/site-astro/src/pages/voice/index.astro b/site-astro/src/pages/voice/index.astro new file mode 100644 index 0000000..94085f2 --- /dev/null +++ b/site-astro/src/pages/voice/index.astro @@ -0,0 +1,207 @@ +--- +// The voice-session page — t16, spec c15/h7 (Nova Sonic 2 voice tutoring). +// The web face of the voice approval gate: signed-out visitors see a static +// sign-in invitation (visible with no JS at all); a signed-in-but-unapproved +// learner sees why the mic is locked; only an admin-approved learner gets the +// mic UI, and even then src/scripts/voice.js opens a WebSocket ONLY after +// POST /api/voice/token returned a grant (the Worker enforces consented + +// approved server-side, and the serverless bridge re-verifies the token at +// $connect — see workers/learn-api/README.md "For t16" and infra/SPIKE.md). +// +// Same Layout/PageHero shell as the consent page, zero new design tokens. +// voice.js is loaded ONLY here (not via Layout.astro) and toggles which +// [data-state] block under [data-voice-root] is shown — the five states are +// documented in voice.js's own header. tests/test_voice_page.py (repo root) +// locks this page's states and voice.js's audited structure. +import Layout from "../../layouts/Layout.astro"; +import PageHero from "../../components/PageHero.astro"; +import site from "../../data/site"; +import { API_BASE } from "../../lib/api"; + +const description = + "Practice out loud: real-time voice tutoring for approved learners, spoken answers back."; +--- + + + + + + +
+
+
+

Sign in to practice out loud

+

+ Voice sessions run against the tutoring tier, so they need a signed-in, + approved account. Nothing is stored about you until you sign in and + accept the Terms of Use and Privacy Policy. +

+ Sign in with GitHub +
+ + + + + + + + +
+
+ + +
+ + diff --git a/site-astro/src/scripts/consent.js b/site-astro/src/scripts/consent.js new file mode 100644 index 0000000..5ac1ba4 --- /dev/null +++ b/site-astro/src/scripts/consent.js @@ -0,0 +1,165 @@ +// consent.js — the pending-consent notice, wired to workers/learn-api's +// consent endpoints (t10; spec c9/c19, "the consent UX shape"; see +// workers/learn-api/README.md's "For t10" section for the exact +// request/response contract this file drives). Loaded ONLY on +// src/pages/consent/index.astro's own