Initial learner accessibilty baseline.

This commit is contained in:
welsberr 2026-03-17 16:41:11 -04:00
parent 51d2874e2f
commit 0f905b5a22
8 changed files with 455 additions and 2 deletions

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Didactopus Learner Session</title>
<style>
:root { color-scheme: light; --bg: #f7f4ed; --panel: #fffdf8; --ink: #1e2b31; --muted: #53656d; --line: #d3c8b7; --accent: #155e63; }
body { margin: 0; font-family: Georgia, 'Times New Roman', serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
a { color: var(--accent); }
.skip { position: absolute; left: 12px; top: 12px; background: #fff; padding: 8px 10px; border: 1px solid var(--line); }
main { max-width: 980px; margin: 0 auto; padding: 24px; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 20px; margin-bottom: 18px; }
h1, h2, h3 { line-height: 1.2; }
ol, ul { padding-left: 22px; }
.meta { color: var(--muted); }
.turn { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.turn:first-of-type { border-top: 0; padding-top: 0; margin-top: 0; }
.fragment { background: #f3efe5; padding: 10px; border-radius: 10px; margin: 8px 0; }
.sr-note { color: var(--muted); font-size: 0.95rem; }
</style>
</head>
<body>
<a class="skip" href="#session-main">Skip to learner session</a>
<main id="session-main" aria-label="Didactopus learner session">
<section aria-labelledby="session-title">
<h1 id="session-title">Didactopus Learner Session</h1>
<p class="sr-note">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.</p>
<p><strong>Learner goal:</strong> Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy.</p>
</section>
<section aria-labelledby="study-plan-title">
<h2 id="study-plan-title">Study Plan</h2>
<ol>
<li>
<h3>Independent Reasoning and Careful Comparison</h3>
<p><strong>Status:</strong> mastered</p>
<p><strong>Prerequisites:</strong> Course Notes and Reference Texts</p>
<p><strong>Supporting lessons:</strong> Independent Reasoning and Careful Comparison</p>
<p><strong>Grounding fragments:</strong></p>
<ul>
<li><div class="fragment"><strong>Independent Reasoning and Careful Comparison</strong> (lesson_body)<br>- 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.</div></li>
<li><div class="fragment"><strong>Independent Reasoning and Careful Comparison</strong> (objective)<br>Explain why the course requires precise comparison of related but non-identical concepts.</div></li>
</ul>
</li>
<li>
<h3>Thermodynamics and Entropy</h3>
<p><strong>Status:</strong> mastered</p>
<p><strong>Prerequisites:</strong> Cryptography and Information Hiding</p>
<p><strong>Supporting lessons:</strong> Thermodynamics and Entropy</p>
<p><strong>Grounding fragments:</strong></p>
<ul>
<li><div class="fragment"><strong>Thermodynamics and Entropy</strong> (lesson_body)<br>- 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.</div></li>
<li><div class="fragment"><strong>Thermodynamics and Entropy</strong> (objective)<br>Explain how thermodynamic entropy relates to, and differs from, Shannon entropy.</div></li>
</ul>
</li>
<li>
<h3>Shannon Entropy</h3>
<p><strong>Status:</strong> mastered</p>
<p><strong>Prerequisites:</strong> Counting and Probability</p>
<p><strong>Supporting lessons:</strong> Shannon Entropy</p>
<p><strong>Grounding fragments:</strong></p>
<ul>
<li><div class="fragment"><strong>Shannon Entropy</strong> (lesson_body)<br>- 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.</div></li>
<li><div class="fragment"><strong>Shannon Entropy</strong> (objective)<br>Explain Shannon entropy as a measure of uncertainty and compare high-entropy and low-entropy sources.</div></li>
</ul>
</li>
</ol>
</section>
<section aria-labelledby="conversation-title">
<h2 id="conversation-title">Conversation</h2>
<article class="turn" aria-label="Conversation turn">
<h3>Learner Goal</h3>
<p class="meta">Role: user</p>
<p>Help me understand how Shannon entropy leads into channel capacity and thermodynamic entropy.</p>
</article>
<article class="turn" aria-label="Conversation turn">
<h3>Didactopus Mentor</h3>
<p class="meta">Role: assistant</p>
<p>[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons</p>
</article>
<article class="turn" aria-label="Conversation turn">
<h3>Didactopus Practice Designer</h3>
<p class="meta">Role: assistant</p>
<p>[stubbed-response] [practice] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons</p>
</article>
<article class="turn" aria-label="Conversation turn">
<h3>Learner Submission</h3>
<p class="meta">Role: user</p>
<p>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.</p>
</article>
<article class="turn" aria-label="Conversation turn">
<h3>Didactopus Evaluator</h3>
<p class="meta">Role: assistant</p>
<p>[stubbed-response] [evaluator] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons</p>
</article>
<article class="turn" aria-label="Conversation turn">
<h3>Didactopus Mentor</h3>
<p class="meta">Role: assistant</p>
<p>[stubbed-response] [mentor] Concept: Independent Reasoning and Careful Comparison Prerequisites: Course Notes and Reference Texts Supporting lessons</p>
</article>
</section>
<section aria-labelledby="evaluation-title">
<h2 id="evaluation-title">Evaluation Summary</h2>
<p><strong>Verdict:</strong> needs_revision</p>
<p><strong>Aggregated dimensions:</strong> {&quot;correctness&quot;: 0.6000000000000001, &quot;critique&quot;: 0.6499999999999999, &quot;explanation&quot;: 0.85}</p>
<p><strong>Follow-up:</strong> Rework the answer so it states the equality/relationship explicitly and explains why it matters.</p>
</section>
</main>
</body>
</html>

View File

@ -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.

View File

@ -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 = [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
"<title>Didactopus Learner Session</title>",
"<style>",
":root { color-scheme: light; --bg: #f7f4ed; --panel: #fffdf8; --ink: #1e2b31; --muted: #53656d; --line: #d3c8b7; --accent: #155e63; }",
"body { margin: 0; font-family: Georgia, 'Times New Roman', serif; background: var(--bg); color: var(--ink); line-height: 1.55; }",
"a { color: var(--accent); }",
".skip { position: absolute; left: 12px; top: 12px; background: #fff; padding: 8px 10px; border: 1px solid var(--line); }",
"main { max-width: 980px; margin: 0 auto; padding: 24px; }",
"section { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 20px; margin-bottom: 18px; }",
"h1, h2, h3 { line-height: 1.2; }",
"ol, ul { padding-left: 22px; }",
".meta { color: var(--muted); }",
".turn { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }",
".turn:first-of-type { border-top: 0; padding-top: 0; margin-top: 0; }",
".fragment { background: #f3efe5; padding: 10px; border-radius: 10px; margin: 8px 0; }",
".sr-note { color: var(--muted); font-size: 0.95rem; }",
"</style>",
"</head>",
"<body>",
'<a class="skip" href="#session-main">Skip to learner session</a>',
'<main id="session-main" aria-label="Didactopus learner session">',
'<section aria-labelledby="session-title">',
'<h1 id="session-title">Didactopus Learner Session</h1>',
'<p class="sr-note">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.</p>',
f"<p><strong>Learner goal:</strong> {_escape(session.get('goal', ''))}</p>",
"</section>",
'<section aria-labelledby="study-plan-title">',
'<h2 id="study-plan-title">Study Plan</h2>',
'<ol>',
]
for step in steps:
body.append("<li>")
body.append(f"<h3>{_escape(step.get('title', ''))}</h3>")
body.append(f"<p><strong>Status:</strong> {_escape(step.get('status', ''))}</p>")
body.append(
f"<p><strong>Prerequisites:</strong> {_escape(', '.join(step.get('prerequisite_titles', []) or ['none explicit']))}</p>"
)
body.append(
f"<p><strong>Supporting lessons:</strong> {_escape(', '.join(step.get('supporting_lessons', []) or ['none listed']))}</p>"
)
fragments = step.get("source_fragments", [])[:2]
if fragments:
body.append("<p><strong>Grounding fragments:</strong></p>")
body.append("<ul>")
for fragment in fragments:
body.append(
f'<li><div class="fragment"><strong>{_escape(fragment.get("lesson_title", ""))}</strong> '
f'({_escape(fragment.get("kind", "fragment"))})<br>{_escape(fragment.get("text", ""))}</div></li>'
)
body.append("</ul>")
body.append("</li>")
body.extend(
[
"</ol>",
"</section>",
'<section aria-labelledby="conversation-title">',
'<h2 id="conversation-title">Conversation</h2>',
]
)
for turn in turns:
body.append('<article class="turn" aria-label="Conversation turn">')
body.append(f"<h3>{_escape(turn.get('label', turn.get('role', 'Turn')))}</h3>")
body.append(f"<p class=\"meta\">Role: {_escape(turn.get('role', ''))}</p>")
body.append(f"<p>{_escape(turn.get('content', ''))}</p>")
body.append("</article>")
body.extend(
[
"</section>",
'<section aria-labelledby="evaluation-title">',
'<h2 id="evaluation-title">Evaluation Summary</h2>',
f"<p><strong>Verdict:</strong> {_escape(evaluation.get('verdict', ''))}</p>",
f"<p><strong>Aggregated dimensions:</strong> {_escape(json.dumps(evaluation.get('aggregated', {}), sort_keys=True))}</p>",
f"<p><strong>Follow-up:</strong> {_escape(evaluation.get('follow_up', ''))}</p>",
"</section>",
"</main>",
"</body>",
"</html>",
]
)
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)}

View File

@ -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))

View File

@ -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()

View File

@ -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"]