Skip to content

Commit ae02e80

Browse files
feat: add adaptive assessment session
1 parent 964596e commit ae02e80

4 files changed

Lines changed: 173 additions & 0 deletions

File tree

src/nokaman/api/app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from nokaman import __version__
4+
from nokaman.eval.adaptive import adaptive_session
45
from nokaman.eval.metrics import placement_test
56
from nokaman.eval.pipeline import evaluate_demo, evaluate_text
67
from nokaman.rubrics.registry import SUPPORTED_LANGUAGES
@@ -25,6 +26,12 @@ class PlacementReq(BaseModel):
2526
answers: list[str] = Field(..., min_length=1)
2627

2728

29+
class AdaptiveReq(BaseModel):
30+
language: str = "en"
31+
answers: list[str] = Field(default_factory=list)
32+
administered_ids: list[str] = Field(default_factory=list)
33+
34+
2835
@app.get("/health")
2936
def health() -> dict:
3037
return {
@@ -54,3 +61,10 @@ def assess_placement(req: PlacementReq) -> dict:
5461
if req.language not in SUPPORTED_LANGUAGES:
5562
raise HTTPException(400, f"unsupported language {req.language}")
5663
return placement_test(req.language, req.answers)
64+
65+
66+
@app.post("/assess/adaptive")
67+
def assess_adaptive(req: AdaptiveReq) -> dict:
68+
if req.language not in SUPPORTED_LANGUAGES:
69+
raise HTTPException(400, f"unsupported language {req.language}")
70+
return adaptive_session(req.language, req.answers, administered_ids=req.administered_ids)

src/nokaman/eval/adaptive.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
5+
from nokaman.models.cefr import score_to_cefr
6+
from nokaman.models.toy import ToyAbilityModel
7+
from nokaman.rubrics.registry import get_language_meta
8+
9+
CEFR_TARGETS = {
10+
"A1": 22.0,
11+
"A2": 42.0,
12+
"B1": 57.0,
13+
"B2": 72.0,
14+
"C1": 84.0,
15+
"C2": 94.0,
16+
}
17+
18+
19+
def adaptive_session(
20+
language: str,
21+
answers: list[str] | None = None,
22+
administered_ids: list[str] | None = None,
23+
) -> dict:
24+
code = language.strip().lower()
25+
meta = get_language_meta(code)
26+
answer_list = [answer for answer in (answers or []) if answer.strip()]
27+
answer_estimates = estimate_answer_scores(code, answer_list)
28+
ability_score = _running_ability(answer_estimates)
29+
administered = set(administered_ids or [])
30+
next_prompt = select_next_prompt(
31+
ability_score=ability_score,
32+
prompt_bank=build_prompt_bank(code),
33+
administered_ids=administered,
34+
)
35+
return {
36+
"language": code,
37+
"language_name": meta["name"],
38+
"n_answers": len(answer_list),
39+
"ability_score": round(ability_score, 2),
40+
"cefr": score_to_cefr(ability_score),
41+
"answer_estimates": answer_estimates,
42+
"next_prompt": next_prompt,
43+
"complete": next_prompt is None,
44+
"model": "AdaptiveHeuristicSession",
45+
}
46+
47+
48+
def estimate_answer_scores(language: str, answers: list[str]) -> list[dict]:
49+
model = ToyAbilityModel(language=language)
50+
estimates = []
51+
for index, answer in enumerate(answers, start=1):
52+
scored = model.score_text(answer, skill="writing")
53+
estimates.append(
54+
{
55+
"answer_index": index,
56+
"score": scored["score"],
57+
"cefr": scored["cefr"],
58+
"tokens": scored["features"]["tokens"],
59+
}
60+
)
61+
return estimates
62+
63+
64+
def select_next_prompt(
65+
ability_score: float,
66+
prompt_bank: Iterable[dict],
67+
administered_ids: set[str] | list[str] | None = None,
68+
) -> dict | None:
69+
administered = set(administered_ids or [])
70+
candidates = [prompt for prompt in prompt_bank if str(prompt["id"]) not in administered]
71+
if not candidates:
72+
return None
73+
target = max(0.0, min(100.0, float(ability_score)))
74+
return min(
75+
candidates,
76+
key=lambda prompt: (
77+
abs(float(prompt["target_score"]) - target),
78+
float(prompt["target_score"]),
79+
str(prompt["id"]),
80+
),
81+
)
82+
83+
84+
def build_prompt_bank(language: str) -> list[dict]:
85+
code = language.strip().lower()
86+
meta = get_language_meta(code)
87+
language_name = meta["name"]
88+
templates = {
89+
"A1": f"Introduce yourself in simple {language_name} sentences.",
90+
"A2": f"Describe your daily routine in {language_name} with times and places.",
91+
"B1": f"Explain a recent problem you solved while learning {language_name}.",
92+
"B2": f"Compare two study strategies and defend your preference in {language_name}.",
93+
"C1": f"Analyze how culture affects communication style in {language_name}.",
94+
"C2": f"Write a nuanced argument about language policy and education in {language_name}.",
95+
}
96+
return [
97+
{
98+
"id": f"{code}_{band.lower()}_adaptive",
99+
"language": code,
100+
"difficulty_cefr": band,
101+
"target_score": target,
102+
"prompt": templates[band],
103+
}
104+
for band, target in CEFR_TARGETS.items()
105+
]
106+
107+
108+
def _running_ability(answer_estimates: list[dict]) -> float:
109+
if not answer_estimates:
110+
return CEFR_TARGETS["A2"]
111+
return sum(float(item["score"]) for item in answer_estimates) / len(answer_estimates)

tests/test_adaptive.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from nokaman.eval.adaptive import adaptive_session, build_prompt_bank, select_next_prompt
4+
5+
6+
def test_select_next_prompt_uses_nearest_unadministered_prompt() -> None:
7+
prompt_bank = [
8+
{"id": "a2", "target_score": 42.0},
9+
{"id": "b1", "target_score": 57.0},
10+
{"id": "b2", "target_score": 72.0},
11+
]
12+
selected = select_next_prompt(ability_score=60.0, prompt_bank=prompt_bank, administered_ids={"b1"})
13+
assert selected is not None
14+
assert selected["id"] == "b2"
15+
16+
17+
def test_adaptive_session_starts_at_a2_prompt() -> None:
18+
result = adaptive_session("en", answers=[])
19+
assert result["ability_score"] == 42.0
20+
assert result["cefr"] == "A2"
21+
assert result["next_prompt"]["difficulty_cefr"] == "A2"
22+
assert not result["complete"]
23+
24+
25+
def test_adaptive_session_tracks_answer_estimates_and_skips_ids() -> None:
26+
first_prompt = build_prompt_bank("en")[1]
27+
result = adaptive_session(
28+
"en",
29+
answers=["I study English every day because it helps me talk with more people."],
30+
administered_ids=[first_prompt["id"]],
31+
)
32+
assert result["n_answers"] == 1
33+
assert result["answer_estimates"][0]["tokens"] > 0
34+
assert result["next_prompt"]["id"] != first_prompt["id"]

tests/test_api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,17 @@ def test_assess_text() -> None:
2424
)
2525
assert r.status_code == 200
2626
assert "cefr" in r.json()
27+
28+
29+
def test_assess_adaptive() -> None:
30+
r = client.post(
31+
"/assess/adaptive",
32+
json={
33+
"language": "en",
34+
"answers": ["I study English every day because it helps me travel."],
35+
},
36+
)
37+
assert r.status_code == 200
38+
payload = r.json()
39+
assert payload["model"] == "AdaptiveHeuristicSession"
40+
assert payload["next_prompt"]["language"] == "en"

0 commit comments

Comments
 (0)