diff --git a/README.md b/README.md index b106fab..bfabd83 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,12 @@ POST /api/v1/attempt } ``` +The `word`, `theme`, and every `phonics_tags` entry must belong to the +canonical curriculum word bank. Attempts that reference a word, theme, or +phonics tag outside the curriculum are rejected with `422` and never reach the +learning profile, and accepted values are normalized to their canonical +(lowercase) form before storage. + ### Get Word Recommendations ``` POST /api/v1/recommend diff --git a/api/routes.py b/api/routes.py index 2f0a2c2..1e548e0 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, Field +from agent.ai_safety import UnsafeContentError, validate_theme, validate_word from agent.auth import ( Account, authorize_student, @@ -152,6 +153,37 @@ def _word_bank_http_error(exc: WordBankError) -> HTTPException: return HTTPException(status_code=422, detail=str(exc)) +def _validate_attempt_payload(req: AttemptRequest) -> tuple[str, str, list[str]]: + """Reject attempts that reference content outside the canonical curriculum. + + Attempts write directly into the learning profile (``words``, + ``theme_preferences``, ``phonics_struggles``), which feeds the recommender, + difficulty calibration, and reports. A client could otherwise fabricate + arbitrary words, themes, or phonics tags and permanently pollute that data. + This boundary check pins every attempt to a canonical curriculum word with + its real theme and phonics tags, and returns the normalized values to store + so the profile never accumulates duplicate casing/spelling variants. + """ + try: + word = validate_word(req.word) + theme = validate_theme(req.theme) + entry = get_word_entry(word) # validate_word guarantees membership + except (UnsafeContentError, WordNotFoundError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if theme != entry["theme"]: + raise HTTPException( + status_code=422, + detail=f"theme '{theme}' does not match the curriculum entry for word '{word}'.", + ) + unknown_tags = sorted(set(req.phonics_tags) - set(entry["phonics"])) + if unknown_tags: + raise HTTPException( + status_code=422, + detail=f"phonics_tags contains tags not present on word '{word}': {unknown_tags}", + ) + return word, theme, req.phonics_tags + + # --- Endpoints --- @router.post("/profile", status_code=status.HTTP_201_CREATED) @@ -181,13 +213,16 @@ def create_student_profile(req: ProfileCreateRequest, account: Account = Depends def submit_attempt(req: AttemptRequest, account: Account = Depends(require_account)): """Record a word attempt and update the student's learning profile.""" authorize_student(account, req.student_id) + # Curriculum boundary checks run before anything touches the profile so + # fabricated attempts cannot pollute the learning profile (issue #22). + word, theme, phonics_tags = _validate_attempt_payload(req) profile = record_attempt( req.student_id, - req.word, + word, req.success, req.time_taken_seconds, - req.phonics_tags, - req.theme, + phonics_tags, + theme, req.difficulty, consent_metadata=_consent_dict(req.consent_metadata), ) @@ -200,7 +235,7 @@ def submit_attempt(req: AttemptRequest, account: Account = Depends(require_accou "student_ref": pseudonymize(req.student_id), # Learning content stays out of logs: the word is reduced to a # bounded length bucket and the outcome to success/failure. - "word_length_bucket": word_length_bucket(len(req.word)), + "word_length_bucket": word_length_bucket(len(word)), "outcome": "success" if req.success else "failure", "time_taken_seconds": req.time_taken_seconds, }, diff --git a/tests/test_agent.py b/tests/test_agent.py index 673a279..4f9416a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -617,6 +617,71 @@ def test_phonics_neighbors(self, client): assert "phonics_neighbors" in r.json() +# ── Attempt Boundary Validation Tests ────────────────────────────────────── + +class TestAttemptBoundaryValidation: + """Attempts must reference canonical curriculum words, themes, and phonics + tags (issue #22); fabricated curriculum fields never reach the profile.""" + + @pytest.fixture + def client(self): + from fastapi.testclient import TestClient + + from main import app + return TestClient(app) + + def _attempt(self, client, **overrides): + payload = { + "student_id": "api_student", + "word": "cat", + "success": True, + "time_taken_seconds": 5.0, + "phonics_tags": ["CVC"], + "theme": "animals", + "difficulty": 1, + "consent_metadata": CONSENT_METADATA, + } + payload.update(overrides) + return client.post("/api/v1/attempt", json=payload, headers=auth()) + + def _profile(self): + from agent.profiler import load_profile + + return load_profile("api_student", create_if_missing=False) + + def test_unknown_word_is_rejected(self, client): + create_consented_profile("api_student") + r = self._attempt(client, word="quizzical") + assert r.status_code == 422 + assert self._profile()["words"] == {} + + def test_unknown_theme_is_rejected(self, client): + create_consented_profile("api_student") + r = self._attempt(client, theme="spaceships") + assert r.status_code == 422 + assert self._profile()["theme_preferences"] == {} + + def test_theme_must_match_the_words_curriculum_theme(self, client): + create_consented_profile("api_student") + r = self._attempt(client, theme="food") # "cat" is an "animals" word + assert r.status_code == 422 + assert self._profile()["theme_preferences"] == {} + + def test_unknown_phonics_tag_is_rejected(self, client): + create_consented_profile("api_student") + r = self._attempt(client, success=False, phonics_tags=["CVC", "zzz-fake"]) + assert r.status_code == 422 + assert self._profile()["phonics_struggles"] == {} + + def test_attempt_is_canonicalized_before_storage(self, client): + r = self._attempt(client, word="CAT", theme="ANIMALS") + assert r.status_code == 200 + profile = self._profile() + assert "cat" in profile["words"] + assert "CAT" not in profile["words"] + assert "animals" in profile["theme_preferences"] + + # ── Onboarding Diagnostic Tests ───────────────────────────────────────────── class TestOnboardingDiagnostic: diff --git a/tests/test_log_privacy.py b/tests/test_log_privacy.py index f129848..524a793 100644 --- a/tests/test_log_privacy.py +++ b/tests/test_log_privacy.py @@ -43,7 +43,7 @@ STUDENT_ID = "leak_probe_student" FOREIGN_STUDENT_ID = "leak_probe_foreign_student" -ATTEMPTED_WORD = "quizzical" +ATTEMPTED_WORD = "elephant" HINT_WORD = "cat" STORY_WORDS = ["cat", "hat"] STORY_SENTENCE_FRAGMENT = "went on a big adventure" @@ -348,7 +348,7 @@ def test_success_paths(self, client, captured_logs): "word": ATTEMPTED_WORD, "success": True, "time_taken_seconds": 7.5, - "phonics_tags": ["CVC"], + "phonics_tags": ["multisyllabic"], "theme": "animals", "difficulty": 2, }, @@ -363,7 +363,7 @@ def test_success_paths(self, client, captured_logs): "word": ATTEMPTED_WORD, "success": False, "time_taken_seconds": 12.0, - "phonics_tags": ["CVC"], + "phonics_tags": ["multisyllabic"], "theme": "animals", "difficulty": 3, }, @@ -417,7 +417,7 @@ def test_failure_paths(self, client, captured_logs): "word": ATTEMPTED_WORD, "success": True, "time_taken_seconds": 1.0, - "phonics_tags": ["CVC"], + "phonics_tags": ["multisyllabic"], "theme": "animals", "difficulty": 1, }, @@ -432,7 +432,7 @@ def test_failure_paths(self, client, captured_logs): "word": ATTEMPTED_WORD, "success": True, "time_taken_seconds": 1.0, - "phonics_tags": ["CVC"], + "phonics_tags": ["multisyllabic"], "theme": "animals", "difficulty": 1, },