Skip to content

Commit ede078a

Browse files
feat: add listening proxy scorer (#44)
1 parent d657d57 commit ede078a

6 files changed

Lines changed: 289 additions & 1 deletion

File tree

data/listening/en_a2_commute.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"id": "en_a2_commute",
3+
"language": "en",
4+
"skill": "listening",
5+
"difficulty_cefr": "A2",
6+
"expected_cefr": "A2",
7+
"transcript": "Maya takes the bus to the library after breakfast. She meets Omar at nine thirty and they study English together.",
8+
"questions": [
9+
{
10+
"id": "q1",
11+
"type": "mcq",
12+
"prompt": "Where does Maya go after breakfast?",
13+
"choices": {
14+
"A": "the market",
15+
"B": "the library",
16+
"C": "the station"
17+
},
18+
"answer": "B",
19+
"response": "B"
20+
},
21+
{
22+
"id": "q2",
23+
"type": "mcq",
24+
"prompt": "Who does Maya meet?",
25+
"choices": {
26+
"A": "Omar",
27+
"B": "her teacher",
28+
"C": "her sister"
29+
},
30+
"answer": "A",
31+
"response": "C"
32+
},
33+
{
34+
"id": "q3",
35+
"type": "short_answer",
36+
"prompt": "How does Maya travel?",
37+
"accepted_answers": ["bus", "the bus"],
38+
"keywords": ["bus"],
39+
"response": "by bus"
40+
}
41+
]
42+
}

data/listening/en_b1_meeting.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"id": "en_b1_meeting",
3+
"language": "en",
4+
"skill": "listening",
5+
"difficulty_cefr": "B1",
6+
"expected_cefr": "B1",
7+
"transcript": "The project meeting moved from Tuesday morning to Wednesday afternoon because the designer was traveling. The team will send questions by email before lunch.",
8+
"questions": [
9+
{
10+
"id": "q1",
11+
"type": "mcq",
12+
"prompt": "Why did the meeting move?",
13+
"choices": {
14+
"A": "the designer was traveling",
15+
"B": "the room was closed",
16+
"C": "the manager was sick"
17+
},
18+
"answer": "A",
19+
"response": "A",
20+
"weight": 1.5
21+
},
22+
{
23+
"id": "q2",
24+
"type": "mcq",
25+
"prompt": "When is the meeting now?",
26+
"choices": {
27+
"A": "Tuesday morning",
28+
"B": "Wednesday afternoon",
29+
"C": "Friday afternoon"
30+
},
31+
"answer": "B",
32+
"response": "B"
33+
},
34+
{
35+
"id": "q3",
36+
"type": "short_answer",
37+
"prompt": "What should the team send before lunch?",
38+
"accepted_answers": ["questions", "questions by email"],
39+
"keywords": ["questions", "email"],
40+
"response": "questions by email"
41+
},
42+
{
43+
"id": "q4",
44+
"type": "short_answer",
45+
"prompt": "Which day was the original meeting?",
46+
"accepted_answers": ["Tuesday", "Tuesday morning"],
47+
"response": "Wednesday"
48+
}
49+
]
50+
}

src/nokaman/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def data_dir() -> Path:
1818

1919

2020
SAMPLES_DIR = data_dir() / "samples"
21+
LISTENING_DIR = data_dir() / "listening"
2122
RUBRICS_DIR = data_dir() / "rubrics"
2223
OUT_DIR = data_dir() / "out"
2324
RUNS_DIR = data_dir() / "runs"

src/nokaman/data/loader.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from pathlib import Path
55

6-
from nokaman.config import RUBRICS_DIR, SAMPLES_DIR
6+
from nokaman.config import LISTENING_DIR, RUBRICS_DIR, SAMPLES_DIR
77

88

99
def list_sample_files(directory: Path | None = None) -> list[Path]:
@@ -20,6 +20,13 @@ def list_rubric_files(directory: Path | None = None) -> list[Path]:
2020
return sorted(root.glob("*.json"))
2121

2222

23+
def list_listening_pack_files(directory: Path | None = None) -> list[Path]:
24+
root = directory or LISTENING_DIR
25+
if not root.exists():
26+
return []
27+
return sorted(root.glob("*.json"))
28+
29+
2330
def load_json(path: Path) -> dict:
2431
return json.loads(path.read_text(encoding="utf-8"))
2532

@@ -33,6 +40,16 @@ def load_sample(path: Path) -> dict:
3340
return payload
3441

3542

43+
def load_listening_pack(path: Path) -> dict:
44+
payload = load_json(path)
45+
payload.setdefault("id", path.stem)
46+
payload.setdefault("language", "en")
47+
payload.setdefault("skill", "listening")
48+
payload.setdefault("questions", payload.get("items") or [])
49+
payload["items"] = list(payload.get("items") or payload.get("questions") or [])
50+
return payload
51+
52+
3653
def load_rubric(path: Path) -> dict:
3754
payload = load_json(path)
3855
payload.setdefault("language", path.stem)

src/nokaman/eval/listening.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from pathlib import Path
5+
from typing import Any
6+
7+
from nokaman.data.loader import load_listening_pack
8+
from nokaman.models.cefr import compare_bands, score_to_cefr
9+
from nokaman.rubrics.registry import get_language_meta
10+
11+
_DIFFICULTY_BASE = {
12+
"A1": 22.0,
13+
"A2": 42.0,
14+
"B1": 57.0,
15+
"B2": 72.0,
16+
"C1": 84.0,
17+
"C2": 94.0,
18+
}
19+
20+
21+
def evaluate_listening_pack_file(path: Path) -> dict:
22+
pack = load_listening_pack(path)
23+
result = score_listening_pack(pack)
24+
expected = pack.get("expected_cefr")
25+
if expected:
26+
result["band_check"] = compare_bands(result["cefr"], str(expected))
27+
result["source"] = str(path)
28+
return result
29+
30+
31+
def score_listening_pack(pack: dict[str, Any]) -> dict:
32+
language = str(pack.get("language") or "en").strip().lower()
33+
meta = get_language_meta(language)
34+
items = list(pack.get("items") or pack.get("questions") or [])
35+
learner_answers = pack.get("answers") if isinstance(pack.get("answers"), dict) else {}
36+
37+
scored_items = []
38+
earned = 0.0
39+
possible = 0.0
40+
for index, item in enumerate(items, start=1):
41+
item_id = str(item.get("id") or f"q{index}")
42+
response = item.get("response")
43+
if response is None:
44+
response = learner_answers.get(item_id)
45+
weight = _positive_weight(item.get("weight", 1.0))
46+
credit = _score_item(item, response)
47+
earned += credit * weight
48+
possible += weight
49+
scored_items.append(
50+
{
51+
"id": item_id,
52+
"type": str(item.get("type") or "mcq"),
53+
"weight": weight,
54+
"response": response,
55+
"credit": round(credit, 4),
56+
"correct": credit >= 1.0,
57+
}
58+
)
59+
60+
accuracy = (earned / possible * 100.0) if possible else 0.0
61+
difficulty = str(pack.get("difficulty_cefr") or pack.get("expected_cefr") or "").upper()
62+
score = _ability_score(accuracy, difficulty)
63+
cefr = score_to_cefr(score)
64+
return {
65+
"id": pack.get("id"),
66+
"language": language,
67+
"language_name": meta["name"],
68+
"skill": "listening",
69+
"score": round(score, 2),
70+
"accuracy": round(accuracy, 2),
71+
"cefr": cefr,
72+
"difficulty_cefr": difficulty or None,
73+
"n_items": len(items),
74+
"earned_weight": round(earned, 4),
75+
"possible_weight": round(possible, 4),
76+
"items": scored_items,
77+
"model": "ListeningProxyScorer",
78+
}
79+
80+
81+
def _score_item(item: dict[str, Any], response: object) -> float:
82+
kind = str(item.get("type") or "mcq").strip().lower()
83+
normalized_response = _normalize(response)
84+
if not normalized_response:
85+
return 0.0
86+
87+
accepted = [_normalize(value) for value in _accepted_answers(item)]
88+
accepted = [value for value in accepted if value]
89+
if normalized_response in accepted:
90+
return 1.0
91+
92+
if kind in {"short_answer", "short", "free_text"}:
93+
keywords = [_normalize(value) for value in item.get("keywords", [])]
94+
keywords = [value for value in keywords if value]
95+
if keywords:
96+
hits = sum(1 for keyword in keywords if keyword in normalized_response)
97+
return hits / len(keywords)
98+
99+
return 0.0
100+
101+
102+
def _accepted_answers(item: dict[str, Any]) -> list[object]:
103+
raw = item.get("accepted_answers")
104+
if raw is None:
105+
raw = item.get("answer", item.get("correct"))
106+
if isinstance(raw, list):
107+
return raw
108+
return [raw]
109+
110+
111+
def _ability_score(accuracy: float, difficulty: str) -> float:
112+
if difficulty in _DIFFICULTY_BASE:
113+
score = _DIFFICULTY_BASE[difficulty] + (accuracy - 70.0) * 0.5
114+
else:
115+
score = accuracy
116+
return max(0.0, min(100.0, score))
117+
118+
119+
def _positive_weight(value: object) -> float:
120+
try:
121+
weight = float(value)
122+
except (TypeError, ValueError):
123+
return 1.0
124+
return weight if weight > 0 else 1.0
125+
126+
127+
def _normalize(value: object) -> str:
128+
text = "" if value is None else str(value)
129+
text = text.strip().lower()
130+
text = re.sub(r"[^\w\s:.-]", " ", text, flags=re.UNICODE)
131+
return re.sub(r"\s+", " ", text).strip()

tests/test_listening.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from nokaman.data.loader import list_listening_pack_files, load_listening_pack
4+
from nokaman.eval.listening import evaluate_listening_pack_file, score_listening_pack
5+
6+
7+
def test_listening_loader_reads_fixture_packs() -> None:
8+
files = list_listening_pack_files()
9+
assert len(files) >= 2
10+
pack = load_listening_pack(files[0])
11+
assert pack["skill"] == "listening"
12+
assert pack["items"]
13+
14+
15+
def test_score_listening_pack_supports_mcq_and_short_answer() -> None:
16+
result = score_listening_pack(
17+
{
18+
"id": "inline_pack",
19+
"language": "en",
20+
"difficulty_cefr": "B1",
21+
"questions": [
22+
{"id": "q1", "type": "mcq", "answer": "A", "response": "A"},
23+
{"id": "q2", "type": "mcq", "answer": "B", "response": "C"},
24+
{
25+
"id": "q3",
26+
"type": "short_answer",
27+
"accepted_answers": ["by email"],
28+
"keywords": ["email", "lunch"],
29+
"response": "They send it by email before lunch.",
30+
"weight": 2,
31+
},
32+
],
33+
}
34+
)
35+
assert result["skill"] == "listening"
36+
assert result["accuracy"] == 75.0
37+
assert result["cefr"] == "B1"
38+
assert result["items"][2]["credit"] == 1.0
39+
40+
41+
def test_evaluate_listening_fixture_includes_band_check() -> None:
42+
path = next(path for path in list_listening_pack_files() if path.name == "en_a2_commute.json")
43+
result = evaluate_listening_pack_file(path)
44+
assert result["language"] == "en"
45+
assert result["n_items"] == 3
46+
assert result["band_check"]["exact_match"]
47+
assert result["source"].endswith("en_a2_commute.json")

0 commit comments

Comments
 (0)