From 0f905b5a22ec0d3ef6ec12c6cb77121fbccf7b3e Mon Sep 17 00:00:00 2001 From: welsberr Date: Tue, 17 Mar 2026 16:41:11 -0400 Subject: [PATCH] Initial learner accessibilty baseline. --- README.md | 9 + docs/learner-accessibility.md | 57 +++++++ examples/ocw-information-entropy-session.html | 116 +++++++++++++ examples/ocw-information-entropy-session.txt | 53 ++++++ src/didactopus/learner_accessibility.py | 158 ++++++++++++++++++ src/didactopus/learner_session_demo.py | 19 ++- tests/test_learner_accessibility.py | 43 +++++ tests/test_learner_session.py | 2 + 8 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 docs/learner-accessibility.md create mode 100644 examples/ocw-information-entropy-session.html create mode 100644 examples/ocw-information-entropy-session.txt create mode 100644 src/didactopus/learner_accessibility.py create mode 100644 tests/test_learner_accessibility.py diff --git a/README.md b/README.md index 6e78d8d..c77054d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Then open: - `examples/ocw-information-entropy-run/learner_progress.html` - `examples/ocw-information-entropy-session.json` +- `examples/ocw-information-entropy-session.html` +- `examples/ocw-information-entropy-session.txt` - `examples/ocw-information-entropy-skill-demo/skill_demo.md` - `examples/ocw-information-entropy-rolemesh-transcript/rolemesh_transcript.md` - `skills/ocw-information-entropy-agent/` @@ -38,6 +40,7 @@ That gives you: - a generated topic pack - a graph-grounded mentor/practice/evaluator learner session +- accessible HTML and text-first learner-session outputs - a visible learning path - progress artifacts - a reusable skill grounded in the exported knowledge @@ -182,6 +185,11 @@ That demo builds a graph-grounded session from the MIT OCW skill bundle and emit The point of this module is architectural as much as demonstrational: it is the session core that future accessibility, model-benchmark, and voice-interaction work should build on. +The learner-session demo also writes accessible companion outputs: + +- `examples/ocw-information-entropy-session.html` +- `examples/ocw-information-entropy-session.txt` + The first benchmark harness for that session core is now: ```bash @@ -441,6 +449,7 @@ What remains heuristic or lightweight: ## Recommended Reading - [docs/roadmap.md](docs/roadmap.md) +- [docs/learner-accessibility.md](docs/learner-accessibility.md) - [docs/local-model-benchmark.md](docs/local-model-benchmark.md) - [docs/course-to-pack.md](docs/course-to-pack.md) - [docs/learning-graph.md](docs/learning-graph.md) diff --git a/docs/learner-accessibility.md b/docs/learner-accessibility.md new file mode 100644 index 0000000..f9ea883 --- /dev/null +++ b/docs/learner-accessibility.md @@ -0,0 +1,57 @@ +# Learner Accessibility + +Didactopus should make the learner loop usable without assuming visual graph navigation or silent waiting on slow local models. + +The current accessibility baseline is built on the graph-grounded learner session backend. + +## Current Outputs + +Running: + +```bash +python -m didactopus.learner_session_demo +``` + +now writes: + +- `examples/ocw-information-entropy-session.json` +- `examples/ocw-information-entropy-session.html` +- `examples/ocw-information-entropy-session.txt` + +## What The Accessible Outputs Do + +The HTML output is meant to be screen-reader-friendly and keyboard-friendly: + +- skip link to the main content +- semantic headings +- reading-order sections for study plan, conversation, and evaluation +- grounded source fragments rendered as ordinary text instead of only visual diagrams + +The plain-text output is a linearized learner-session transcript that is suitable for: + +- terminal reading +- screen-reader reading +- low-bandwidth sharing +- future text-to-speech pipelines + +## Why This Matters + +Didactopus should help learners work with structure, not just with pictures and dashboards. + +This is especially important for: + +- blind learners +- screen-reader users +- learners on low-power hardware +- situations where audio or text needs to be generated locally + +## Relationship To The Roadmap + +This is the accessibility baseline, not the endpoint. + +Likely next steps: + +- local text-to-speech for mentor, practice, and evaluator turns +- speech-to-text for learner answers +- explicit spoken structural cues +- text-first alternatives for more generated visualizations diff --git a/examples/ocw-information-entropy-session.html b/examples/ocw-information-entropy-session.html new file mode 100644 index 0000000..7e32f1f --- /dev/null +++ b/examples/ocw-information-entropy-session.html @@ -0,0 +1,116 @@ + + + + + +Didactopus Learner Session + + + + +
+
+

Didactopus Learner Session

+

This page is structured for keyboard and screen-reader use. It presents the learner goal, study plan, grounded source fragments, and conversation turns in reading order.

+

Learner goal: Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy.

+
+
+

Study Plan

+
    +
  1. +

    Independent Reasoning and Careful Comparison

    +

    Status: mastered

    +

    Prerequisites: Course Notes and Reference Texts

    +

    Supporting lessons: Independent Reasoning and Careful Comparison

    +

    Grounding fragments:

    +
      +
    • Independent Reasoning and Careful Comparison (lesson_body)
      - Objective: Explain why the course requires precise comparison of related but non-identical concepts. +- Exercise: Write a short note distinguishing Shannon entropy, channel capacity, and thermodynamic entropy. +The syllabus framing implies a style of work where analogy is useful but dangerous when used loosely. Learners must compare models carefully, state assumptions, and notice where similar mathematics does not imply identical interpretation.
    • +
    • Independent Reasoning and Careful Comparison (objective)
      Explain why the course requires precise comparison of related but non-identical concepts.
    • +
    +
  2. +
  3. +

    Thermodynamics and Entropy

    +

    Status: mastered

    +

    Prerequisites: Cryptography and Information Hiding

    +

    Supporting lessons: Thermodynamics and Entropy

    +

    Grounding fragments:

    +
      +
    • Thermodynamics and Entropy (lesson_body)
      - Objective: Explain how thermodynamic entropy relates to, and differs from, Shannon entropy. +- Exercise: Compare the two entropy notions and identify what is preserved across the analogy. +The course uses entropy as a bridge concept between communication theory and physics while insisting on careful interpretation.
    • +
    • Thermodynamics and Entropy (objective)
      Explain how thermodynamic entropy relates to, and differs from, Shannon entropy.
    • +
    +
  4. +
  5. +

    Shannon Entropy

    +

    Status: mastered

    +

    Prerequisites: Counting and Probability

    +

    Supporting lessons: Shannon Entropy

    +

    Grounding fragments:

    +
      +
    • Shannon Entropy (lesson_body)
      - Objective: Explain Shannon entropy as a measure of uncertainty and compare high-entropy and low-entropy sources. +- Exercise: Compute the entropy of a Bernoulli source and interpret the result. +The course then introduces entropy as a quantitative measure of uncertainty for a source model and uses it to reason about representation cost and surprise.
    • +
    • Shannon Entropy (objective)
      Explain Shannon entropy as a measure of uncertainty and compare high-entropy and low-entropy sources.
    • +
    +
  6. +
+
+
+

Conversation

+
+

Learner Goal

+

Role: user

+

Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy.

+
+
+

Didactopus Mentor

+

Role: assistant

+

[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons

+
+
+

Didactopus Practice Designer

+

Role: assistant

+

[stubbed-response] [practice] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons

+
+
+

Learner Submission

+

Role: user

+

Entropy measures uncertainty because more possible outcomes require more information to describe, but one limitation is that thermodynamic entropy is not identical to Shannon entropy.

+
+
+

Didactopus Evaluator

+

Role: assistant

+

[stubbed-response] [evaluator] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons

+
+
+

Didactopus Mentor

+

Role: assistant

+

[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons

+
+
+
+

Evaluation Summary

+

Verdict: needs_revision

+

Aggregated dimensions: {"correctness": 0.6000000000000001, "critique": 0.6499999999999999, "explanation": 0.85}

+

Follow-up: Rework the answer so it states the equality/relationship explicitly and explains why it matters.

+
+
+ + \ No newline at end of file diff --git a/examples/ocw-information-entropy-session.txt b/examples/ocw-information-entropy-session.txt new file mode 100644 index 0000000..f16cfc9 --- /dev/null +++ b/examples/ocw-information-entropy-session.txt @@ -0,0 +1,53 @@ +Didactopus Learner Session + +Learner goal: Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy. + +Study plan: +1. Independent Reasoning and Careful Comparison + Status: mastered + Prerequisites: Course Notes and Reference Texts + Supporting lessons: Independent Reasoning and Careful Comparison + Source fragment (lesson_body): - Objective: Explain why the course requires precise comparison of related but non-identical concepts. +- Exercise: Write a short note distinguishing Shannon entropy, channel capacity, and thermodynamic entropy. +The syllabus framing implies a style of work where analogy is useful but dangerous when used loosely. Learners must compare models carefully, state assumptions, and notice where similar mathematics does not imply identical interpretation. + Source fragment (objective): Explain why the course requires precise comparison of related but non-identical concepts. +2. Thermodynamics and Entropy + Status: mastered + Prerequisites: Cryptography and Information Hiding + Supporting lessons: Thermodynamics and Entropy + Source fragment (lesson_body): - Objective: Explain how thermodynamic entropy relates to, and differs from, Shannon entropy. +- Exercise: Compare the two entropy notions and identify what is preserved across the analogy. +The course uses entropy as a bridge concept between communication theory and physics while insisting on careful interpretation. + Source fragment (objective): Explain how thermodynamic entropy relates to, and differs from, Shannon entropy. +3. Shannon Entropy + Status: mastered + Prerequisites: Counting and Probability + Supporting lessons: Shannon Entropy + Source fragment (lesson_body): - Objective: Explain Shannon entropy as a measure of uncertainty and compare high-entropy and low-entropy sources. +- Exercise: Compute the entropy of a Bernoulli source and interpret the result. +The course then introduces entropy as a quantitative measure of uncertainty for a source model and uses it to reason about representation cost and surprise. + Source fragment (objective): Explain Shannon entropy as a measure of uncertainty and compare high-entropy and low-entropy sources. + +Conversation: +Learner Goal: +Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy. + +Didactopus Mentor: +[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons + +Didactopus Practice Designer: +[stubbed-response] [practice] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons + +Learner Submission: +Entropy measures uncertainty because more possible outcomes require more information to describe, but one limitation is that thermodynamic entropy is not identical to Shannon entropy. + +Didactopus Evaluator: +[stubbed-response] [evaluator] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons + +Didactopus Mentor: +[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons + +Evaluation summary: +Verdict: needs_revision +Aggregated dimensions: {"correctness": 0.6000000000000001, "critique": 0.6499999999999999, "explanation": 0.85} +Follow-up: Rework the answer so it states the equality/relationship explicitly and explains why it matters. diff --git a/src/didactopus/learner_accessibility.py b/src/didactopus/learner_accessibility.py new file mode 100644 index 0000000..4bdce13 --- /dev/null +++ b/src/didactopus/learner_accessibility.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import html +import json +from pathlib import Path + + +def _escape(value: object) -> str: + return html.escape(str(value)) + + +def build_accessible_session_text(session: dict) -> str: + lines = [ + "Didactopus Learner Session", + "", + f"Learner goal: {session.get('goal', '')}", + "", + "Study plan:", + ] + for index, step in enumerate(session.get("study_plan", {}).get("steps", []), start=1): + lines.extend( + [ + f"{index}. {step.get('title', '')}", + f" Status: {step.get('status', '')}", + f" Prerequisites: {', '.join(step.get('prerequisite_titles', []) or ['none explicit'])}", + f" Supporting lessons: {', '.join(step.get('supporting_lessons', []) or ['none listed'])}", + ] + ) + for fragment in step.get("source_fragments", [])[:2]: + lines.append(f" Source fragment ({fragment.get('kind', 'fragment')}): {fragment.get('text', '')}") + lines.extend( + [ + "", + "Conversation:", + ] + ) + for turn in session.get("turns", []): + lines.extend( + [ + f"{turn.get('label', turn.get('role', 'Turn'))}:", + str(turn.get("content", "")), + "", + ] + ) + evaluation = session.get("evaluation", {}) + lines.extend( + [ + "Evaluation summary:", + f"Verdict: {evaluation.get('verdict', '')}", + f"Aggregated dimensions: {json.dumps(evaluation.get('aggregated', {}), sort_keys=True)}", + f"Follow-up: {evaluation.get('follow_up', '')}", + ] + ) + return "\n".join(lines).strip() + "\n" + + +def build_accessible_session_html(session: dict) -> str: + steps = session.get("study_plan", {}).get("steps", []) + turns = session.get("turns", []) + evaluation = session.get("evaluation", {}) + body = [ + "", + '', + "", + '', + '', + "Didactopus Learner Session", + "", + "", + "", + '', + '
', + '
', + '

Didactopus Learner Session

', + '

This page is structured for keyboard and screen-reader use. It presents the learner goal, study plan, grounded source fragments, and conversation turns in reading order.

', + f"

Learner goal: {_escape(session.get('goal', ''))}

", + "
", + '
', + '

Study Plan

', + '
    ', + ] + for step in steps: + body.append("
  1. ") + body.append(f"

    {_escape(step.get('title', ''))}

    ") + body.append(f"

    Status: {_escape(step.get('status', ''))}

    ") + body.append( + f"

    Prerequisites: {_escape(', '.join(step.get('prerequisite_titles', []) or ['none explicit']))}

    " + ) + body.append( + f"

    Supporting lessons: {_escape(', '.join(step.get('supporting_lessons', []) or ['none listed']))}

    " + ) + fragments = step.get("source_fragments", [])[:2] + if fragments: + body.append("

    Grounding fragments:

    ") + body.append("
      ") + for fragment in fragments: + body.append( + f'
    • {_escape(fragment.get("lesson_title", ""))} ' + f'({_escape(fragment.get("kind", "fragment"))})
      {_escape(fragment.get("text", ""))}
    • ' + ) + body.append("
    ") + body.append("
  2. ") + body.extend( + [ + "
", + "
", + '
', + '

Conversation

', + ] + ) + for turn in turns: + body.append('
') + body.append(f"

{_escape(turn.get('label', turn.get('role', 'Turn')))}

") + body.append(f"

Role: {_escape(turn.get('role', ''))}

") + body.append(f"

{_escape(turn.get('content', ''))}

") + body.append("
") + body.extend( + [ + "
", + '
', + '

Evaluation Summary

', + f"

Verdict: {_escape(evaluation.get('verdict', ''))}

", + f"

Aggregated dimensions: {_escape(json.dumps(evaluation.get('aggregated', {}), sort_keys=True))}

", + f"

Follow-up: {_escape(evaluation.get('follow_up', ''))}

", + "
", + "
", + "", + "", + ] + ) + return "\n".join(body) + + +def render_accessible_session_outputs( + session: dict, + *, + out_html: str | Path, + out_text: str | Path, +) -> dict[str, str]: + out_html = Path(out_html) + out_text = Path(out_text) + out_html.write_text(build_accessible_session_html(session), encoding="utf-8") + out_text.write_text(build_accessible_session_text(session), encoding="utf-8") + return {"html": str(out_html), "text": str(out_text)} diff --git a/src/didactopus/learner_session_demo.py b/src/didactopus/learner_session_demo.py index df44d14..832bef1 100644 --- a/src/didactopus/learner_session_demo.py +++ b/src/didactopus/learner_session_demo.py @@ -4,6 +4,7 @@ import json from pathlib import Path from .config import load_config +from .learner_accessibility import render_accessible_session_outputs from .learner_session import build_graph_grounded_session from .model_provider import ModelProvider from .ocw_skill_agent_demo import load_ocw_skill_context @@ -13,6 +14,8 @@ def run_learner_session_demo( config_path: str | Path, skill_dir: str | Path, out_path: str | Path | None = None, + accessible_html_path: str | Path | None = None, + accessible_text_path: str | Path | None = None, ) -> dict: config = load_config(config_path) provider = ModelProvider(config.model_provider) @@ -24,7 +27,11 @@ def run_learner_session_demo( learner_submission="Entropy measures uncertainty because more possible outcomes require more information to describe, but one limitation is that thermodynamic entropy is not identical to Shannon entropy.", ) if out_path is not None: - Path(out_path).write_text(json.dumps(payload, indent=2), encoding="utf-8") + out_path = Path(out_path) + out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + html_path = Path(accessible_html_path) if accessible_html_path is not None else out_path.with_suffix(".html") + text_path = Path(accessible_text_path) if accessible_text_path is not None else out_path.with_suffix(".txt") + render_accessible_session_outputs(payload, out_html=html_path, out_text=text_path) return payload @@ -36,8 +43,16 @@ def main() -> None: parser.add_argument("--config", default=str(root / "configs" / "config.example.yaml")) parser.add_argument("--skill-dir", default=str(root / "skills" / "ocw-information-entropy-agent")) parser.add_argument("--out", default=str(root / "examples" / "ocw-information-entropy-session.json")) + parser.add_argument("--accessible-html", default=None) + parser.add_argument("--accessible-text", default=None) args = parser.parse_args() - payload = run_learner_session_demo(args.config, args.skill_dir, args.out) + payload = run_learner_session_demo( + args.config, + args.skill_dir, + args.out, + args.accessible_html, + args.accessible_text, + ) print(json.dumps(payload, indent=2)) diff --git a/tests/test_learner_accessibility.py b/tests/test_learner_accessibility.py new file mode 100644 index 0000000..7259343 --- /dev/null +++ b/tests/test_learner_accessibility.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from didactopus.learner_accessibility import ( + build_accessible_session_html, + build_accessible_session_text, + render_accessible_session_outputs, +) +from didactopus.learner_session_demo import run_learner_session_demo + + +def _session_payload() -> dict: + root = Path(__file__).resolve().parents[1] + return run_learner_session_demo( + root / "configs" / "config.example.yaml", + root / "skills" / "ocw-information-entropy-agent", + ) + + +def test_accessible_session_html_has_landmarks() -> None: + html = build_accessible_session_html(_session_payload()) + assert 'href="#session-main"' in html + assert 'aria-label="Didactopus learner session"' in html + assert "Study Plan" in html + assert "Conversation" in html + assert "Evaluation Summary" in html + + +def test_accessible_session_text_is_linearized() -> None: + text = build_accessible_session_text(_session_payload()) + assert "Learner goal:" in text + assert "Study plan:" in text + assert "Conversation:" in text + assert "Evaluation summary:" in text + + +def test_render_accessible_session_outputs_writes_files(tmp_path: Path) -> None: + outputs = render_accessible_session_outputs( + _session_payload(), + out_html=tmp_path / "session.html", + out_text=tmp_path / "session.txt", + ) + assert Path(outputs["html"]).exists() + assert Path(outputs["text"]).exists() diff --git a/tests/test_learner_session.py b/tests/test_learner_session.py index 6b853b7..49c4ffc 100644 --- a/tests/test_learner_session.py +++ b/tests/test_learner_session.py @@ -35,5 +35,7 @@ def test_run_learner_session_demo_writes_output(tmp_path: Path) -> None: ) assert (tmp_path / "session.json").exists() + assert (tmp_path / "session.html").exists() + assert (tmp_path / "session.txt").exists() assert payload["practice_task"] assert payload["evaluation"]["aggregated"]