Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
)
Expand All @@ -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,
},
Expand Down
65 changes: 65 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions tests/test_log_privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down
Loading