Skip to content

Commit c0ad6ef

Browse files
m-messerclaude
andcommitted
feat: response areas with evaluation-function parameters, confirmed in the wizard
in2lambda can now carry a response area (input type, reference answer, evaluation function, and its parameters) from the source document through to the imported Lambda Feedback JSON, instead of always emitting "responseAreas": []. - api: new `ResponseArea` dataclass; `Part.response_areas`; `Question.add_response_area()`. - response_areas/: registry of the 14 documented evaluation functions (plus evaluatePython / callLLM) with their response types and parameter specs, and per-type `config` defaults. Unknown functions/params are passed through untouched. - json_convert: emits populated `responseAreas` from a new `minimal_template_response_area.json`, mirroring the first answer into `answerContent` when empty. Parts with none still emit []. - Markdown filter: a ```lambda-feedback fenced JSON block under a ## Solution becomes a ResponseArea; `validation` warns (non-fatally) on malformed blocks. - wizard: the LLM proposes a response area per part from the worked solution; `in2lambda wizard` stops on each for [a]ccept / [e]dit / [s]kip (edit menu driven by the registry), then writes the confirmed block into the markdown. New --yes / --no-response-areas flags; the prompt is skipped when not attached to a terminal. The emitted shape is verified byte-for-byte against real exported questions (tests/fixtures/response_areas/). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HXaWoYhb2yCFwv6xCmK3zT
1 parent f0b50d9 commit c0ad6ef

25 files changed

Lines changed: 1473 additions & 37 deletions

conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
collect_ignore = [
1111
"in2lambda/wizard/extract.py",
1212
"in2lambda/wizard/run.py",
13+
"in2lambda/wizard/confirm.py",
1314
]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Response Areas
2+
3+
A **response area** is where a student answers part of a question and how that
4+
answer is auto-marked. in2lambda can carry response areas from the source
5+
document all the way into the imported Lambda Feedback JSON.
6+
7+
## The model
8+
9+
{class}`in2lambda.api.response_area.ResponseArea` holds four things:
10+
11+
| Field | Meaning | Import JSON |
12+
| --- | --- | --- |
13+
| `response_type` | input widget - `EXPRESSION`, `NUMBER`, `NUMERIC_UNITS`, `BOOLEAN`, `TEXT`, `ESSAY`, `CODE`, ... | `response.responseInput.responseType` |
14+
| `answer` | the reference answer, as a string | `response.responseInput.answer` |
15+
| `evaluation_function` | which evaluation function grades the response | `evaluationFunctionName` |
16+
| `grade_params` | that function's parameters | `gradeParams` |
17+
18+
`config` (extra `responseInput` settings, e.g. `{"language": "python"}` for
19+
`CODE`) and `pre_response_text` / `post_response_text` round it out. Everything
20+
else in the on-import entry - feedback colours, `inputSymbols`, `tests`,
21+
`cases`, ... - comes from
22+
`in2lambda/json_convert/minimal_template_response_area.json` and is not something
23+
a filter sets.
24+
25+
A filter attaches one with
26+
{meth}`~in2lambda.api.question.Question.add_response_area`:
27+
28+
```python
29+
from in2lambda.api.response_area import ResponseArea
30+
31+
set.current_question.add_response_area(
32+
ResponseArea("EXPRESSION", "pi*d", "compareExpressions", {"rtol": 0.01})
33+
)
34+
```
35+
36+
Parts with no response area import exactly as before, with `"responseAreas": []`.
37+
38+
## The evaluation-function registry
39+
40+
{data}`in2lambda.response_areas.EVALUATION_FUNCTIONS` is in2lambda's model of the
41+
functions documented in the [Lambda Feedback evaluation-function
42+
reference](https://docs.lambdafeedback.com/teacher/reference/evaluation_functions/).
43+
Each entry lists the response types it pairs with and its parameters (name,
44+
type, default, help). It drives what `in2lambda wizard` proposes and what its
45+
confirmation prompt offers.
46+
47+
`compareExpressions` and `comparePhysicalQuantities` have complete parameter
48+
lists; the others carry their common parameters and a `TODO` pointing at the
49+
per-function docs page. Nothing is *enforced* - `json_convert` passes an unknown
50+
function or parameter straight through, so a newer function still imports, it
51+
just is not offered in the menu.
52+
53+
## The `lambda-feedback` block
54+
55+
The [`Markdown` filter](../filters/_autosummary/Markdown) reads a fenced code
56+
block whose info string is `lambda-feedback`, placed under the part or its
57+
`## Solution`:
58+
59+
````markdown
60+
## Solution
61+
62+
$C = \pi d$.
63+
64+
```lambda-feedback
65+
{
66+
"responseType": "EXPRESSION",
67+
"answer": "pi*d",
68+
"evaluationFunction": "compareExpressions",
69+
"gradeParams": {"rtol": 0.01}
70+
}
71+
```
72+
````
73+
74+
The body is JSON with the keys `responseType`, `answer`, `evaluationFunction`,
75+
`gradeParams`, and optionally `config`, `preResponseText`, `postResponseText`.
76+
`in2lambda wizard` writes these blocks after you confirm its suggestions (see
77+
[Wizard](../wizard)); you can also hand-write or edit them. Malformed blocks are
78+
reported by {func}`in2lambda.validation.check_markdown` as a warning - conversion
79+
still goes ahead, the block is just ignored.

docs/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ wizard
7272
contributing/installation
7373
contributing/high_level
7474
contributing/writing-a-filter
75+
contributing/response-areas
7576
contributing/documentation
7677
contributing/new_version
7778
```

docs/source/wizard.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,36 @@ $ in2lambda wizard problem_sheet.pdf -o draft.md
3636
sub-questions, and `## Solution` blocks. Any figures found in a PDF are saved
3737
next to it under `media/`.
3838

39+
### Confirming response areas
40+
41+
For every part that has a worked solution the wizard also proposes a **response
42+
area** - the input type a student uses and the evaluation function that marks it
43+
(see [Response areas](contributing/response-areas)). It stops on each one so you
44+
can check it:
45+
46+
```text
47+
Question 2, part 1
48+
Q: Find the time it takes to reach the ground.
49+
Solution: $t = \sqrt{2h/g}$.
50+
Proposed: EXPRESSION / compareExpressions answer='sqrt(2*h/g)' params={}
51+
Why: The answer is a symbolic expression in h and g.
52+
[a]ccept / [e]dit / [s]kip [a]:
53+
```
54+
55+
`e` walks you through picking the response type, the evaluation function and its
56+
parameters; `s` drops it. Confirmed response areas are written into `draft.md` as
57+
```` ```lambda-feedback ```` blocks, which `in2lambda convert ... Markdown` turns
58+
into real response areas on import.
59+
60+
| Flag | Effect |
61+
| --- | --- |
62+
| _(default, in a terminal)_ | prompt for each part |
63+
| `--yes` / `-y` | keep every proposed response area without prompting |
64+
| `--no-response-areas` | do not add any response areas |
65+
66+
The prompt is also skipped (all suggestions kept) when the wizard is not attached
67+
to a terminal, so scripts keep working.
68+
3969
Read through `draft.md`, fix anything the model got wrong, then convert it:
4070

4171
```bash

in2lambda/api/part.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""A part of a question."""
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
4+
5+
from in2lambda.api.response_area import ResponseArea
46

57

68
@dataclass
@@ -9,3 +11,6 @@ class Part:
911

1012
text: str = ""
1113
worked_solution: str = ""
14+
# Kept out of repr so the many doctests that assert on Part(...) / Question(...)
15+
# output stay valid; response areas are inspected explicitly where they matter.
16+
response_areas: list[ResponseArea] = field(default_factory=list, repr=False)

in2lambda/api/question.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import panflute as pf
77

88
from in2lambda.api.part import Part
9+
from in2lambda.api.response_area import ResponseArea
910

1011

1112
@dataclass
@@ -147,3 +148,36 @@ def add_part_text(self, elem: Union[pf.Element, str]) -> None:
147148
self.parts[self._last_part["text"]].text = elem_text
148149

149150
self._last_part["text"] += 1
151+
152+
def add_response_area(
153+
self, response_area: ResponseArea, *, to_part: int = -1
154+
) -> None:
155+
"""Attach a response area to one of this question's parts.
156+
157+
If the question has no parts yet, an empty part is created to hold it,
158+
mirroring how :meth:`add_solution` and :meth:`add_part_text` cope with
159+
content arriving in an unexpected order.
160+
161+
Args:
162+
response_area: The response area to attach.
163+
to_part: Index of the part to attach to (default: the last part).
164+
165+
Examples:
166+
>>> from in2lambda.api.question import Question
167+
>>> from in2lambda.api.response_area import ResponseArea
168+
>>> question = Question()
169+
>>> question.add_part_text("What is 2 + 2?")
170+
>>> question.add_response_area(ResponseArea("NUMBER", "4", "isExactEqual"))
171+
>>> question.parts[0].response_areas
172+
[ResponseArea(response_type='NUMBER', answer='4', \
173+
evaluation_function='isExactEqual', grade_params={}, config={}, \
174+
pre_response_text='', post_response_text='')]
175+
>>> # Works even before any part text has been added.
176+
>>> blank = Question()
177+
>>> blank.add_response_area(ResponseArea("BOOLEAN", "True", "compareBoolean"))
178+
>>> len(blank.parts)
179+
1
180+
"""
181+
if not self.parts:
182+
self.parts.append(Part())
183+
self.parts[to_part].response_areas.append(response_area)

in2lambda/api/response_area.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""A response area: where a student answers, and how that answer is marked.
2+
3+
A :class:`ResponseArea` bundles the four things Lambda Feedback needs to
4+
auto-mark one input on a question part:
5+
6+
* ``response_type`` - the kind of input widget (``EXPRESSION``, ``NUMBER``,
7+
``BOOLEAN``, ``TEXT``, ``ESSAY``, ``CODE``, ``NUMERIC_UNITS``, ...),
8+
* ``answer`` - the reference answer, as a string,
9+
* ``evaluation_function`` - which evaluation function grades the response
10+
(see :data:`in2lambda.response_areas.EVALUATION_FUNCTIONS`),
11+
* ``grade_params`` - that function's parameters.
12+
13+
:mod:`in2lambda.json_convert` turns each one into an entry in a part's
14+
``responseAreas`` array on import.
15+
"""
16+
17+
from dataclasses import dataclass, field
18+
19+
20+
@dataclass
21+
class ResponseArea:
22+
"""One markable input on a question part.
23+
24+
Examples:
25+
>>> from in2lambda.api.response_area import ResponseArea
26+
>>> ResponseArea("EXPRESSION", "pi*d", "compareExpressions", {"rtol": 0.01})
27+
ResponseArea(response_type='EXPRESSION', answer='pi*d', \
28+
evaluation_function='compareExpressions', grade_params={'rtol': 0.01}, config={}, \
29+
pre_response_text='', post_response_text='')
30+
"""
31+
32+
response_type: str
33+
answer: str
34+
evaluation_function: str
35+
grade_params: dict = field(default_factory=dict)
36+
config: dict = field(default_factory=dict)
37+
pre_response_text: str = ""
38+
post_response_text: str = ""

in2lambda/filters/Markdown/filter.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,54 @@
2222
:mod:`in2lambda.validation` checks it before conversion.
2323
"""
2424

25+
import json
2526
from typing import Optional
2627

2728
import panflute as pf
2829

2930
from in2lambda.api.part import Part
31+
from in2lambda.api.response_area import ResponseArea
3032
from in2lambda.api.set import Set
3133
from in2lambda.filters.markdown import filter
3234

3335
_SOLUTION_HEADING = "solution"
36+
_RESPONSE_AREA_CLASS = "lambda-feedback"
37+
38+
39+
def _attach_response_area(block_text: str, state: "_State", set: Set) -> None:
40+
"""Parse a ``lambda-feedback`` fenced block and attach it to the current part.
41+
42+
Malformed blocks are ignored here - :mod:`in2lambda.validation` reports them
43+
to the user before conversion.
44+
45+
Args:
46+
block_text: The raw JSON body of the fenced block.
47+
state: The current parser state (its ``part`` receives the response area).
48+
set: The Python API the question is being built into.
49+
"""
50+
try:
51+
data = json.loads(block_text)
52+
except json.JSONDecodeError:
53+
return
54+
if not isinstance(data, dict):
55+
return
56+
57+
grade_params = data.get("gradeParams")
58+
config = data.get("config")
59+
response_area = ResponseArea(
60+
response_type=str(data.get("responseType", "")),
61+
answer=str(data.get("answer", "")),
62+
evaluation_function=str(data.get("evaluationFunction", "")),
63+
grade_params=grade_params if isinstance(grade_params, dict) else {},
64+
config=config if isinstance(config, dict) else {},
65+
pre_response_text=str(data.get("preResponseText", "")),
66+
post_response_text=str(data.get("postResponseText", "")),
67+
)
68+
69+
if state.part is None:
70+
state.part = Part()
71+
set.current_question.parts.append(state.part)
72+
state.part.response_areas.append(response_area)
3473

3574

3675
class _State:
@@ -86,6 +125,15 @@ def pandoc_filter(
86125
return None
87126

88127
state = _state_for(doc)
128+
129+
# A ```lambda-feedback fenced block configures a response area for the
130+
# current part. Handle it before the generic text handling below, which
131+
# would otherwise fold the JSON into the part or solution text.
132+
if isinstance(elem, pf.CodeBlock) and _RESPONSE_AREA_CLASS in elem.classes:
133+
if not parsing_answers:
134+
_attach_response_area(elem.text, state, set)
135+
return None
136+
89137
is_heading = isinstance(elem, pf.Header)
90138
text = pf.stringify(elem).strip()
91139

0 commit comments

Comments
 (0)