Merge learner workbench pilot from bwng checkout

This commit is contained in:
welsberr 2026-04-25 07:58:47 -04:00
parent 3837bd2316
commit 25aae8ef54
18 changed files with 1147 additions and 39 deletions

View File

@ -0,0 +1,60 @@
# evo-edu Evidence Trail Pilot
This note captures the first concrete integration path between `evo-edu.org`
and Didactopus.
## Why this pack first
`Evidence Trail` is the best first pilot because it already matches the
strongest current Didactopus behaviors:
- question framing
- source-grounded study
- concept sequencing
- reflective revision
- bibliography growth
It is a better fit for the first learner-workbench pilot than a more
model-heavy pack such as `Population Change`.
## Current repo artifacts
This pilot now has a first pack skeleton in:
- `domain-packs/evidence-trail/pack.yaml`
- `domain-packs/evidence-trail/concepts.yaml`
- `domain-packs/evidence-trail/pack_compliance_manifest.json`
- `domain-packs/evidence-trail/pack.frontend.json`
and a learner-facing frontend payload copy in:
- `webui/public/packs/evidence-trail-pack.json`
## Immediate next UI step
The current `webui/` is still review-workbench-first. The next useful step is
to add a learner-workbench path that can load `evidence-trail-pack.json` and
show:
- onboarding headline/body/checklist
- current concept
- prerequisite path
- one grounded prompt
- learner response
- evaluator feedback
- next study action
## Integration back to evo-edu.org
Once a learner-workbench route exists, `evo-edu.org` should link to it from:
- `/evo/packs/evidence-trail/`
- `/evo/packs/evidence-trail/study-log.html`
- `/evo/pathways/self-learners/`
The framing should remain:
- guided-study workbench
- source-grounded reflection companion
not generic AI tutor.

View File

@ -0,0 +1,59 @@
concepts:
- id: question-framing
title: Question Framing
description: >-
Start from a question narrow enough to investigate and revise. A good
study question gives the learner a way to compare evidence rather than
only gather disconnected facts.
prerequisites: []
mastery_signals:
- State a focused question that can be investigated with tools and sources.
- Distinguish the main question from broader background curiosity.
mastery_profile: {}
- id: observation-vs-interpretation
title: Observation Versus Interpretation
description: >-
Separate what the model, species account, or source actually shows from
the explanation you currently prefer. This distinction is necessary for
trustworthy revision.
prerequisites:
- question-framing
mastery_signals:
- Describe a model or source observation before drawing a conclusion.
- Mark which parts of an explanation are inference rather than direct observation.
mastery_profile: {}
- id: source-comparison
title: Source Comparison
description: >-
Compare sources by relevance, grounding, and limits rather than treating
all sources as equally strong. The learner should note where a source
supports, complicates, or fails to resolve a claim.
prerequisites:
- observation-vs-interpretation
mastery_signals:
- Explain why one source is stronger or weaker for the current question.
- Identify at least one unresolved conflict or limitation across sources.
mastery_profile: {}
- id: bibliography-growth
title: Bibliography Growth
description: >-
Extend a question through linked citations, related species records, and
literature trails. Bibliography growth should deepen the inquiry rather
than inflate the reading list without purpose.
prerequisites:
- source-comparison
mastery_signals:
- Add a useful new source that sharpens or tests the current explanation.
- Explain how a new citation changes the study path.
mastery_profile: {}
- id: revision-under-uncertainty
title: Revision Under Uncertainty
description: >-
Treat revision as a normal part of inquiry. The learner should be able to
state what changed, why it changed, and what uncertainty still remains.
prerequisites:
- bibliography-growth
mastery_signals:
- Write a revised explanation after comparing evidence.
- State one uncertainty or next evidence move instead of pretending closure.
mastery_profile: {}

View File

@ -0,0 +1,70 @@
{
"id": "evidence-trail",
"title": "Evidence Trail",
"subtitle": "Draft evo-edu-aligned pack for guided independent study through question framing, source comparison, bibliography growth, and reflective revision.",
"level": "self-learner",
"concepts": [
{
"id": "question-framing",
"title": "Question Framing",
"prerequisites": [],
"masteryDimension": "mastery",
"exerciseReward": "Question Framing progress recorded"
},
{
"id": "observation-vs-interpretation",
"title": "Observation Versus Interpretation",
"prerequisites": [
"question-framing"
],
"masteryDimension": "mastery",
"exerciseReward": "Observation Versus Interpretation progress recorded"
},
{
"id": "source-comparison",
"title": "Source Comparison",
"prerequisites": [
"observation-vs-interpretation"
],
"masteryDimension": "mastery",
"exerciseReward": "Source Comparison progress recorded"
},
{
"id": "bibliography-growth",
"title": "Bibliography Growth",
"prerequisites": [
"source-comparison"
],
"masteryDimension": "mastery",
"exerciseReward": "Bibliography Growth progress recorded"
},
{
"id": "revision-under-uncertainty",
"title": "Revision Under Uncertainty",
"prerequisites": [
"bibliography-growth"
],
"masteryDimension": "mastery",
"exerciseReward": "Revision Under Uncertainty progress recorded"
}
],
"onboarding": {
"headline": "Start with one question, one source trail, and one explicit revision.",
"body": "Begin with a focused question, compare model output and source material carefully, then record what changed in your understanding and why.",
"checklist": [
"State the question you are trying to answer.",
"Compare one model observation with one cited source or species account.",
"Write one interpretation and one uncertainty.",
"Record what evidence would make you revise your current view."
]
},
"compliance": {
"sources": 6,
"attributionRequired": true,
"shareAlikeRequired": true,
"noncommercialOnly": true,
"flags": [
"derived-from-evo-edu-materials"
]
}
}

View File

@ -0,0 +1,23 @@
name: evidence-trail
display_name: Evidence Trail
version: 0.1.0-draft
schema_version: '1'
didactopus_min_version: 0.1.0
didactopus_max_version: 0.9.99
description: Draft evo-edu-aligned pack for guided independent study through question framing, source comparison, bibliography growth, and reflective revision.
author: Wesley R. Elsberry
license: CC BY-NC-SA 4.0
audience_level: self-learner
dependencies: []
overrides: []
profile_templates: {}
cross_pack_links: []
supporting_artifacts:
- pack_compliance_manifest.json
first_session_headline: Start with one question, one source trail, and one explicit revision.
first_session_body: Begin with a focused question, compare model output and source material carefully, then record what changed in your understanding and why.
first_session_checklist:
- State the question you are trying to answer.
- Compare one model observation with one cited source or species account.
- Write one interpretation and one uncertainty.
- Record what evidence would make you revise your current view.

View File

@ -0,0 +1,42 @@
{
"pack_id": "evidence-trail",
"display_name": "Evidence Trail",
"attribution_required": true,
"share_alike_required": true,
"noncommercial_only": true,
"restrictive_flags": [
"derived-from-evo-edu-materials"
],
"derived_from_sources": [
{
"title": "evo-edu.org Evidence Trail pack",
"kind": "site_pack",
"locator": "https://evo-edu.org/evo/packs/evidence-trail/"
},
{
"title": "evo-edu.org Evidence Trail study log",
"kind": "site_support_material",
"locator": "https://evo-edu.org/evo/packs/evidence-trail/study-log.html"
},
{
"title": "evo-edu.org Scientific Virtues",
"kind": "site_background",
"locator": "https://evo-edu.org/evo/scientific-virtues/"
},
{
"title": "evo-edu.org Notebook",
"kind": "site_background",
"locator": "https://evo-edu.org/evo/notebook/"
},
{
"title": "EcoSpecies",
"kind": "supporting_resource",
"locator": "https://evo-edu.org/apps/ecospecies/"
},
{
"title": "Literature Explorer",
"kind": "supporting_resource",
"locator": "https://evo-edu.org/apps/literature-explorer/"
}
]
}

View File

@ -6,7 +6,7 @@ from .db import Base, engine
from .models import (
LoginRequest, TokenPair, KnowledgeCandidateCreate, KnowledgeCandidateUpdate,
ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest,
CreateLearnerRequest
CreateLearnerRequest, LearnerWorkbenchSessionRequest
)
from .repository import (
authenticate_user, get_user_by_id, create_learner, learner_owned_by_user,
@ -16,6 +16,7 @@ from .repository import (
)
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .synthesis import generate_synthesis_candidates
from .learner_workbench import build_pack_workbench_session
Base.metadata.create_all(bind=engine)
@ -58,6 +59,25 @@ def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_use
create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
@app.post("/api/learner-workbench/session")
def api_learner_workbench_session(payload: LearnerWorkbenchSessionRequest):
try:
return build_pack_workbench_session(
pack_id=payload.pack_id,
concept_id=payload.concept_id,
learner_goal=payload.learner_goal,
question=payload.question,
observation=payload.observation,
interpretation=payload.interpretation,
uncertainty=payload.uncertainty,
revision_trigger=payload.revision_trigger,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@app.post("/api/knowledge-candidates")
def api_create_candidate(payload: KnowledgeCandidateCreate, reviewer = Depends(require_reviewer)):
candidate_id = create_candidate(payload)

View File

@ -27,6 +27,16 @@ def _grounding_block(step: dict) -> str:
return "\n".join(lines)
def _scientific_virtues_block() -> str:
return (
"Scientific virtues operating guidance:\n"
"- Distinguish observation from interpretation.\n"
"- Preserve uncertainty where the evidence does not settle the issue.\n"
"- Treat revision as progress when better evidence changes the conclusion.\n"
"- Prefer source-grounded comparison over unsupported confidence."
)
def _generate_role_text(
provider: ModelProvider,
*,
@ -71,9 +81,10 @@ def build_graph_grounded_session(
mentor_prompt = (
f"{_grounding_block(primary)}\n\n"
f"{_grounding_block(secondary)}\n\n"
f"{_scientific_virtues_block()}\n\n"
f"Learner goal: {learner_goal}\n"
"Respond as Didactopus mentor. Give a short grounded orientation, explain why these concepts come first, "
"and ask one focused question that keeps the learner doing the reasoning."
"ask one focused question that keeps the learner doing the reasoning, and explicitly separate what should be observed from what should be interpreted."
)
mentor_text = _generate_role_text(
provider,
@ -87,8 +98,10 @@ def build_graph_grounded_session(
practice_prompt = (
f"{_grounding_block(primary)}\n\n"
f"{_scientific_virtues_block()}\n\n"
f"Learner goal: {learner_goal}\n"
"Create one reasoning-heavy practice task for the learner. Keep it grounded in the supporting lessons and do not provide the full solution."
"Create one reasoning-heavy practice task for the learner. Keep it grounded in the supporting lessons, do not provide the full solution, "
"and require the learner to identify observation, interpretation, and one condition that would justify revision."
)
practice_text = _generate_role_text(
provider,
@ -103,10 +116,12 @@ def build_graph_grounded_session(
evaluation = evaluate_submission_with_skill(context, primary["concept_key"].split("::", 1)[-1], learner_submission)
evaluator_prompt = (
f"{_grounding_block(primary)}\n\n"
f"{_scientific_virtues_block()}\n\n"
f"Practice task: {practice_text}\n"
f"Learner submission: {learner_submission}\n"
f"Deterministic evaluator result: verdict={evaluation['verdict']}, aggregated={evaluation['aggregated']}\n"
"Respond as Didactopus evaluator. Summarize strengths, real gaps, and one next revision target without pretending supported caveats are missing. "
"Reward honest uncertainty, careful source-grounded distinction between observation and interpretation, and justified revision."
)
evaluator_text = _generate_role_text(
provider,
@ -121,8 +136,10 @@ def build_graph_grounded_session(
next_step_prompt = (
f"{_grounding_block(primary)}\n\n"
f"{_grounding_block(secondary)}\n\n"
f"{_scientific_virtues_block()}\n\n"
f"Evaluator feedback: {evaluator_text}\n"
"Respond as Didactopus mentor. Give the next study action and explain why it follows from the grounded concept path."
"Respond as Didactopus mentor. Give the next study action, explain why it follows from the grounded concept path, "
"and state what new evidence or comparison would most help the learner revise or strengthen the current view."
)
next_step_text = _generate_role_text(
provider,

View File

@ -0,0 +1,196 @@
from __future__ import annotations
from pathlib import Path
import yaml
from .config import AppConfig
from .model_provider import ModelProvider
from .role_prompts import system_prompt_for_role
ROOT = Path(__file__).resolve().parents[2]
DOMAIN_PACKS = ROOT / "domain-packs"
def _load_yaml(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
def load_pack_context(pack_id: str) -> dict:
pack_dir = DOMAIN_PACKS / pack_id
if not pack_dir.exists():
raise FileNotFoundError(f"Unknown pack: {pack_id}")
pack = _load_yaml(pack_dir / "pack.yaml")
concepts = (_load_yaml(pack_dir / "concepts.yaml")).get("concepts", []) or []
by_id = {concept.get("id"): concept for concept in concepts}
return {
"pack_dir": pack_dir,
"pack": pack,
"concepts": concepts,
"by_id": by_id,
}
def _scientific_virtues_block() -> str:
return (
"Scientific virtues operating guidance:\n"
"- Distinguish observation from interpretation.\n"
"- Preserve uncertainty where the evidence does not settle the issue.\n"
"- Treat revision as progress when better evidence changes the conclusion.\n"
"- Prefer source-grounded comparison over unsupported confidence."
)
def _concept_block(concept: dict, by_id: dict[str, dict]) -> str:
prerequisite_titles = [by_id.get(pid, {}).get("title", pid) for pid in concept.get("prerequisites", []) or []]
mastery_signals = concept.get("mastery_signals", []) or []
lines = [
f"Concept: {concept.get('title', '')}",
f"Prerequisites: {', '.join(prerequisite_titles or ['none explicit'])}",
f"Description: {concept.get('description', '')}",
]
if mastery_signals:
lines.append("Mastery signals:")
lines.extend(f"- {item}" for item in mastery_signals)
return "\n".join(lines)
def _generate(provider: ModelProvider, *, role: str, prompt: str, temperature: float, max_tokens: int) -> dict:
response = provider.generate(
prompt,
role=role,
system_prompt=system_prompt_for_role(role),
temperature=temperature,
max_tokens=max_tokens,
)
return {
"text": response.text.strip(),
"provider": response.provider,
"model_name": response.model_name,
}
def _feedback(question: str, observation: str, interpretation: str, uncertainty: str, revision_trigger: str) -> dict:
strengths: list[str] = []
gaps: list[str] = []
if question.strip():
strengths.append("You stated a concrete study question.")
else:
gaps.append("State a clear question before treating the inquiry as settled.")
if observation.strip():
strengths.append("You recorded an explicit observation.")
else:
gaps.append("Record what was observed before moving to interpretation.")
if interpretation.strip():
strengths.append("You proposed an interpretation.")
else:
gaps.append("State the interpretation you currently think fits best.")
if uncertainty.strip():
strengths.append("You preserved uncertainty instead of smoothing it away.")
else:
gaps.append("Name one uncertainty or limitation in the current explanation.")
if revision_trigger.strip():
strengths.append("You identified what evidence could justify revision.")
else:
gaps.append("Say what result or source would make you revise your view.")
return {
"strengths": strengths,
"gaps": gaps,
"next_revision_target": gaps[0] if gaps else "Compare one more source or example before treating the interpretation as stable.",
}
def build_pack_workbench_session(
*,
pack_id: str,
concept_id: str,
learner_goal: str,
question: str,
observation: str,
interpretation: str,
uncertainty: str,
revision_trigger: str,
) -> dict:
context = load_pack_context(pack_id)
pack = context["pack"]
concepts = context["concepts"]
by_id = context["by_id"]
concept = by_id.get(concept_id) or (concepts[0] if concepts else None)
if concept is None:
raise KeyError(f"No concepts found for pack: {pack_id}")
index = concepts.index(concept)
next_concept = concepts[index + 1] if index + 1 < len(concepts) else concept
provider = ModelProvider(AppConfig().model_provider)
pack_block = (
f"Pack: {pack.get('display_name', pack_id)}\n"
f"Description: {pack.get('description', '')}\n"
f"Audience level: {pack.get('audience_level', 'novice-friendly')}"
)
learner_state_block = (
f"Learner goal: {learner_goal}\n"
f"Question: {question}\n"
f"Observation: {observation}\n"
f"Interpretation: {interpretation}\n"
f"Uncertainty: {uncertainty}\n"
f"Revision trigger: {revision_trigger}"
)
mentor = _generate(
provider,
role="mentor",
prompt=(
f"{pack_block}\n\n{_concept_block(concept, by_id)}\n\n{_concept_block(next_concept, by_id)}\n\n"
f"{_scientific_virtues_block()}\n\n{learner_state_block}\n"
"Respond as Didactopus mentor. Give a short grounded orientation, explain why this concept matters now, "
"and ask one focused question that helps the learner distinguish observation from interpretation."
),
temperature=0.2,
max_tokens=260,
)
practice = _generate(
provider,
role="practice",
prompt=(
f"{pack_block}\n\n{_concept_block(concept, by_id)}\n\n{_scientific_virtues_block()}\n\n{learner_state_block}\n"
"Respond as Didactopus practice designer. Create one compact task that asks for evidence comparison, honest uncertainty, and a revision condition."
),
temperature=0.25,
max_tokens=220,
)
evaluator = _generate(
provider,
role="evaluator",
prompt=(
f"{pack_block}\n\n{_concept_block(concept, by_id)}\n\n{_scientific_virtues_block()}\n\n{learner_state_block}\n"
"Respond as Didactopus evaluator. Name strengths first, then real gaps, and give one revision target without pretending stated caveats are absent."
),
temperature=0.2,
max_tokens=240,
)
next_step = _generate(
provider,
role="mentor",
prompt=(
f"{pack_block}\n\n{_concept_block(concept, by_id)}\n\n{_concept_block(next_concept, by_id)}\n\n"
f"{_scientific_virtues_block()}\n\nEvaluator feedback: {evaluator['text']}\n"
"Respond as Didactopus mentor. Give the next study action and say what new evidence or comparison would most help the learner revise or strengthen the current view."
),
temperature=0.2,
max_tokens=220,
)
feedback = _feedback(question, observation, interpretation, uncertainty, revision_trigger)
return {
"pack_id": pack_id,
"concept_id": concept.get("id"),
"concept_title": concept.get("title"),
"next_concept_id": next_concept.get("id"),
"next_concept_title": next_concept.get("title"),
"mentor": mentor,
"practice": practice,
"evaluator": evaluator,
"next_step": next_step,
"feedback": feedback,
}

View File

@ -65,3 +65,14 @@ class MasteryRecord(BaseModel):
class LearnerState(BaseModel):
learner_id: str
records: list[MasteryRecord] = Field(default_factory=list)
class LearnerWorkbenchSessionRequest(BaseModel):
pack_id: str
concept_id: str
learner_goal: str = ""
question: str = ""
observation: str = ""
interpretation: str = ""
uncertainty: str = ""
revision_trigger: str = ""

View File

@ -18,10 +18,17 @@ def _variant_suffix(role: str, variant: str) -> str:
"project_advisor": " Emphasize realistic next steps and avoid grandiose scope.",
"evaluator": " Preserve learner trust by naming strengths first, avoiding invented omissions, and framing revisions as specific improvements rather than blanket criticism.",
},
"scientific_virtues": {
"mentor": " Reinforce curiosity, careful observation, and honest separation of observation from interpretation. Preserve uncertainty where the evidence is incomplete and treat revision as progress.",
"practice": " Design tasks that ask the learner to compare evidence, separate observation from interpretation, and state what result would change their current view.",
"learner": " Keep an earnest learner voice that distinguishes observation from inference, notes uncertainty honestly, and stays willing to revise.",
"project_advisor": " Encourage projects that foreground source quality, evidence comparison, and explicit revision rather than polished unsupported synthesis.",
"evaluator": " Evaluate scientific habits as well as correctness: distinguish observation from interpretation, reward justified revision, preserve caveats, and challenge weakly supported claims without overstating certainty.",
},
"concise": {
"mentor": " Keep the response compact: no more than four short paragraphs or bullets worth of content.",
"practice": " Keep the task compact and direct.",
"learner": " Keep the reflection short and direct.",
"learner": " Keep reflections concise and concrete.",
"project_advisor": " Keep the advice short and concrete.",
"evaluator": " Keep the evaluation compact and specific.",
},
@ -38,13 +45,15 @@ def mentor_system_prompt() -> str:
"Prefer Socratic questions, prerequisite reminders, and hints over finished solutions. "
"When responding to a learner attempt or evaluator note, acknowledge what the learner already did correctly before naming gaps. "
"Do not claim a caveat, limitation, or nuance is missing if the learner already stated one; instead say how to sharpen or extend it. "
"Separate observation from interpretation, name uncertainty when the evidence is incomplete, and frame revision as a normal part of inquiry."
)
def practice_system_prompt() -> str:
return (
"You are Didactopus in practice-design mode. Generate short, reasoning-heavy tasks that force the learner "
"to explain, compare, or derive ideas rather than copy answers."
"to explain, compare, or derive ideas rather than copy answers. Prefer tasks that ask what was observed, what was inferred, "
"what evidence supports the claim, and what result would justify revision."
)
@ -69,6 +78,7 @@ def evaluator_system_prompt() -> str:
"Before saying something is missing, first verify whether the learner already included it. "
"If the learner stated a caveat, limitation, or nuance, quote or paraphrase that part and evaluate its quality rather than pretending it is absent. "
"Do not invent omissions that are contradicted by the learner's actual text. "
"Treat honest revision, explicit uncertainty, and careful separation of observation from interpretation as strengths in scientific reasoning."
)

View File

@ -1,6 +1,11 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_api_files_exist():
assert Path("src/didactopus/api.py").exists()
assert Path("src/didactopus/storage.py").exists()
assert Path("data/packs/bayes-pack.json").exists()
assert (ROOT / "src/didactopus/api.py").exists()
assert (ROOT / "src/didactopus/storage.py").exists()
assert (ROOT / "src/didactopus/learner_workbench.py").exists()
assert (ROOT / "data/packs/bayes-pack.json").exists()

View File

@ -1,5 +1,9 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_frontend_scaffold_exists():
assert Path("webui/src/App.jsx").exists()
assert Path("webui/src/api.js").exists()
assert (ROOT / "webui/src/App.jsx").exists()
assert (ROOT / "webui/src/api.js").exists()

47
tests/test_learner_workbench.py Executable file
View File

@ -0,0 +1,47 @@
from didactopus import learner_workbench as learner_workbench_module
class _FakeProvider:
def __init__(self, _config) -> None:
pass
def generate(
self,
prompt: str,
*,
role: str,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
):
return type(
"Response",
(),
{
"text": f"{role}: {prompt.splitlines()[0]}",
"provider": "fake",
"model_name": "fake-model",
},
)()
def test_build_pack_workbench_session_uses_evidence_trail_pack(monkeypatch) -> None:
monkeypatch.setattr(learner_workbench_module, "ModelProvider", _FakeProvider)
payload = learner_workbench_module.build_pack_workbench_session(
pack_id="evidence-trail",
concept_id="question-framing",
learner_goal="Understand how to compare evidence.",
question="Which question should guide the study step?",
observation="The pack starts with question framing.",
interpretation="Question framing organizes the next evidence move.",
uncertainty="I do not yet know which source will best test the interpretation.",
revision_trigger="A stronger source trail could change the current framing.",
)
assert payload["pack_id"] == "evidence-trail"
assert payload["concept_id"] == "question-framing"
assert payload["next_concept_id"] == "observation-vs-interpretation"
assert payload["mentor"]["provider"] == "fake"
assert payload["feedback"]["strengths"]
assert payload["feedback"]["gaps"] == []

View File

@ -1,6 +1,11 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_ui_files_exist():
assert Path("webui/src/App.jsx").exists()
assert Path("webui/src/storage.js").exists()
assert Path("webui/public/packs/bayes-pack.json").exists()
assert (ROOT / "webui/src/App.jsx").exists()
assert (ROOT / "webui/src/storage.js").exists()
assert (ROOT / "webui/public/packs/bayes-pack.json").exists()
assert (ROOT / "webui/public/packs/evidence-trail-pack.json").exists()

View File

@ -0,0 +1,70 @@
{
"id": "evidence-trail",
"title": "Evidence Trail",
"subtitle": "Draft evo-edu-aligned pack for guided independent study through question framing, source comparison, bibliography growth, and reflective revision.",
"level": "self-learner",
"concepts": [
{
"id": "question-framing",
"title": "Question Framing",
"prerequisites": [],
"masteryDimension": "mastery",
"exerciseReward": "Question Framing progress recorded"
},
{
"id": "observation-vs-interpretation",
"title": "Observation Versus Interpretation",
"prerequisites": [
"question-framing"
],
"masteryDimension": "mastery",
"exerciseReward": "Observation Versus Interpretation progress recorded"
},
{
"id": "source-comparison",
"title": "Source Comparison",
"prerequisites": [
"observation-vs-interpretation"
],
"masteryDimension": "mastery",
"exerciseReward": "Source Comparison progress recorded"
},
{
"id": "bibliography-growth",
"title": "Bibliography Growth",
"prerequisites": [
"source-comparison"
],
"masteryDimension": "mastery",
"exerciseReward": "Bibliography Growth progress recorded"
},
{
"id": "revision-under-uncertainty",
"title": "Revision Under Uncertainty",
"prerequisites": [
"bibliography-growth"
],
"masteryDimension": "mastery",
"exerciseReward": "Revision Under Uncertainty progress recorded"
}
],
"onboarding": {
"headline": "Start with one question, one source trail, and one explicit revision.",
"body": "Begin with a focused question, compare model output and source material carefully, then record what changed in your understanding and why.",
"checklist": [
"State the question you are trying to answer.",
"Compare one model observation with one cited source or species account.",
"Write one interpretation and one uncertainty.",
"Record what evidence would make you revise your current view."
]
},
"compliance": {
"sources": 6,
"attributionRequired": true,
"shareAlikeRequired": true,
"noncommercialOnly": true,
"flags": [
"derived-from-evo-edu-materials"
]
}
}

View File

@ -1,10 +1,78 @@
import React, { useEffect, useState } from "react";
import { login, listCandidates, createCandidate, createReview, promoteCandidate, runSynthesis, listSynthesisCandidates, promoteSynthesis } from "./api";
import React, { useEffect, useMemo, useState } from "react";
import {
login,
listCandidates,
createCandidate,
createReview,
promoteCandidate,
runSynthesis,
listSynthesisCandidates,
promoteSynthesis,
createLearnerWorkbenchSession,
} from "./api";
import {
applyEvidence,
buildMasteryMap,
progressPercent,
recommendNext,
milestoneMessages,
claimReadiness,
} from "./engine";
function LoginView({ onAuth }) {
function LauncherView({ onSelect }) {
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">Didactopus</p>
<h1>Choose a work mode.</h1>
<p>
The current prototype now has two distinct entry points: the existing review workbench
and a learner-workbench pilot using the evo-edu `Evidence Trail` pack.
</p>
</div>
<div className="toolbar">
<button className="primary" onClick={() => onSelect("learner")}>Open learner pilot</button>
<button onClick={() => onSelect("review")}>Open review workbench</button>
</div>
</header>
<main className="grid">
<section className="card">
<h2>Learner workbench pilot</h2>
<p>
Use a guided-study surface built around question framing, source comparison, and
revision under uncertainty.
</p>
<ul className="plain-list">
<li>Loads the `Evidence Trail` pack.</li>
<li>Shows concept path and onboarding.</li>
<li>Separates observation from interpretation.</li>
<li>Treats revision as normal progress.</li>
</ul>
</section>
<section className="card">
<h2>Review workbench</h2>
<p>
Keep using the existing knowledge-candidate and synthesis workflow for pack-improvement
and review operations.
</p>
<ul className="plain-list">
<li>Review learner-derived candidates.</li>
<li>Promote pack improvements and skill bundles.</li>
<li>Inspect synthesis candidates across packs.</li>
</ul>
</section>
</main>
</div>
);
}
function LoginView({ onAuth, onBack }) {
const [username, setUsername] = useState("reviewer");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
async function doLogin() {
try {
const result = await login(username, password);
@ -13,13 +81,24 @@ function LoginView({ onAuth }) {
setError("Login failed");
}
}
return (
<div className="page narrow">
<section className="card">
<p className="eyebrow">Review Workbench</p>
<h1>Didactopus review workbench</h1>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} />
</label>
<label>
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
<div className="toolbar">
<button className="primary" onClick={doLogin}>Login</button>
<button onClick={onBack}>Back</button>
</div>
{error ? <div className="error">{error}</div> : null}
</section>
</div>
@ -30,9 +109,13 @@ function CandidateCard({ candidate, onReview, onPromote }) {
return (
<div className="card small">
<h3>{candidate.title}</h3>
<div className="muted">{candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status}</div>
<div className="muted">
{candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status}
</div>
<p>{candidate.summary}</p>
<div className="tiny">confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)}</div>
<div className="tiny">
confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)}
</div>
<div className="actions">
<button onClick={() => onReview(candidate.candidate_id, "accept_pack_improvement")}>Accept as pack improvement</button>
<button onClick={() => onPromote(candidate.candidate_id, "curriculum_draft")}>Promote to curriculum draft</button>
@ -49,14 +132,15 @@ function SynthesisCard({ item, onPromote }) {
<h3>{item.source_concept_id} {item.target_concept_id}</h3>
<div className="muted">{item.source_pack_id} {item.target_pack_id}</div>
<p>{item.explanation}</p>
<div className="tiny">total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)}</div>
<div className="tiny">
total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)}
</div>
<button onClick={() => onPromote(item.synthesis_id)}>Promote into workflow</button>
</div>
);
}
export default function App() {
const [auth, setAuth] = useState(null);
function ReviewWorkbench({ auth, onBack }) {
const [candidates, setCandidates] = useState([]);
const [synthesis, setSynthesis] = useState([]);
const [message, setMessage] = useState("");
@ -68,9 +152,7 @@ export default function App() {
}
useEffect(() => {
if (auth?.access_token) {
reload(auth.access_token);
}
if (auth?.access_token) reload(auth.access_token);
}, [auth]);
async function seedCandidate() {
@ -87,7 +169,7 @@ export default function App() {
confidence_hint: 0.73,
novelty_score: 0.66,
synthesis_score: 0.42,
triage_lane: "pack_improvement"
triage_lane: "pack_improvement",
};
await createCandidate(auth.access_token, payload);
await reload();
@ -99,7 +181,7 @@ export default function App() {
review_kind: "human_review",
verdict,
rationale: "Accepted in reviewer workbench demo.",
requested_changes: ""
requested_changes: "",
});
await reload();
setMessage(`Review added to candidate ${candidateId}.`);
@ -109,7 +191,7 @@ export default function App() {
await promoteCandidate(auth.access_token, candidateId, {
promotion_target: target,
target_object_id: "",
promotion_status: "approved"
promotion_status: "approved",
});
await reload();
setMessage(`Candidate ${candidateId} promoted to ${target}.`);
@ -127,20 +209,23 @@ export default function App() {
setMessage(`Synthesis candidate ${synthesisId} promoted into workflow.`);
}
if (!auth) return <LoginView onAuth={setAuth} />;
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">Review Workbench</p>
<h1>Review workbench + synthesis engine</h1>
<p>Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles, or archive, while surfacing cross-pack synthesis proposals.</p>
<p>
Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles,
or archive, while surfacing cross-pack synthesis proposals.
</p>
<div className="muted">{message}</div>
</div>
<div className="toolbar">
<button onClick={seedCandidate}>Seed candidate</button>
<button className="primary" onClick={seedCandidate}>Seed candidate</button>
<button onClick={handleRunSynthesis}>Run synthesis</button>
<button onClick={() => reload()}>Refresh</button>
<button onClick={onBack}>Back</button>
</div>
</header>
@ -165,3 +250,356 @@ export default function App() {
</div>
);
}
function LearnerWorkbench({ onBack }) {
const [pack, setPack] = useState(null);
const [error, setError] = useState("");
const [currentConceptId, setCurrentConceptId] = useState("");
const [learnerState, setLearnerState] = useState({ records: [], history: [] });
const [question, setQuestion] = useState("");
const [observation, setObservation] = useState("");
const [interpretation, setInterpretation] = useState("");
const [uncertainty, setUncertainty] = useState("");
const [revisionTrigger, setRevisionTrigger] = useState("");
const [feedback, setFeedback] = useState(null);
const [sessionOutput, setSessionOutput] = useState(null);
useEffect(() => {
let active = true;
fetch("/packs/evidence-trail-pack.json")
.then((res) => {
if (!res.ok) throw new Error("Failed to load evidence-trail pack");
return res.json();
})
.then((data) => {
if (!active) return;
setPack(data);
setCurrentConceptId(data.concepts?.[0]?.id || "");
})
.catch(() => {
if (!active) return;
setError("Could not load the learner-workbench pack.");
});
return () => {
active = false;
};
}, []);
const currentConcept = useMemo(
() => pack?.concepts?.find((concept) => concept.id === currentConceptId) || null,
[pack, currentConceptId]
);
const nextConcept = useMemo(() => {
if (!pack?.concepts || !currentConcept) return null;
const index = pack.concepts.findIndex((concept) => concept.id === currentConcept.id);
return index >= 0 ? pack.concepts[index + 1] || null : null;
}, [pack, currentConcept]);
const masteryMap = useMemo(
() => (pack ? buildMasteryMap(learnerState, pack) : []),
[learnerState, pack]
);
const progress = useMemo(
() => (pack ? progressPercent(learnerState, pack) : 0),
[learnerState, pack]
);
const nextCards = useMemo(
() => (pack ? recommendNext(learnerState, pack) : []),
[learnerState, pack]
);
const readiness = useMemo(
() => (pack ? claimReadiness(learnerState, pack) : null),
[learnerState, pack]
);
const milestones = useMemo(
() => (pack ? milestoneMessages(learnerState, pack) : []),
[learnerState, pack]
);
function advanceConcept() {
if (nextConcept) setCurrentConceptId(nextConcept.id);
}
async function evaluateCurrentWork() {
const score =
(question.trim() ? 0.18 : 0) +
(observation.trim() ? 0.24 : 0) +
(interpretation.trim() ? 0.20 : 0) +
(uncertainty.trim() ? 0.18 : 0) +
(revisionTrigger.trim() ? 0.20 : 0);
const confidenceHint =
0.45 +
(observation.trim() ? 0.10 : 0) +
(interpretation.trim() ? 0.08 : 0) +
(uncertainty.trim() ? 0.08 : 0) +
(revisionTrigger.trim() ? 0.09 : 0);
const nextState = applyEvidence(learnerState, {
concept_id: currentConcept.id,
dimension: currentConcept.masteryDimension || "mastery",
score: Math.min(1, score),
confidence_hint: Math.min(1, confidenceHint),
timestamp: new Date().toISOString(),
note: "Evidence Trail learner-workbench submission",
});
setLearnerState(nextState);
try {
const session = await createLearnerWorkbenchSession({
pack_id: pack.id,
concept_id: currentConcept.id,
learner_goal: question || `Work through ${currentConcept.title} in the Evidence Trail pack.`,
question,
observation,
interpretation,
uncertainty,
revision_trigger: revisionTrigger,
});
setFeedback({
strengths: session.feedback?.strengths || [],
gaps: session.feedback?.gaps || [],
nextRevision: session.feedback?.next_revision_target || "Compare one more source or example before moving on.",
});
setSessionOutput(session);
} catch {
setFeedback({
strengths: [],
gaps: ["Backend session generation failed; local progress was still recorded."],
nextRevision: "Check the learner-workbench API path and retry.",
});
setSessionOutput(null);
}
}
if (error) {
return (
<div className="page narrow">
<section className="card">
<h1>Learner workbench pilot</h1>
<div className="error">{error}</div>
<button onClick={onBack}>Back</button>
</section>
</div>
);
}
if (!pack || !currentConcept) {
return (
<div className="page narrow">
<section className="card">
<h1>Learner workbench pilot</h1>
<p>Loading the `Evidence Trail` pack...</p>
</section>
</div>
);
}
return (
<div className="page learner-page">
<header className="hero learner-hero">
<div>
<p className="eyebrow">Learner Workbench Pilot</p>
<h1>{pack.title}</h1>
<p>{pack.subtitle}</p>
<div className="muted">
This pilot uses scientific virtues as operating rules: separate observation from interpretation,
preserve uncertainty, and treat revision as progress.
</div>
</div>
<div className="toolbar">
<button className="primary" onClick={advanceConcept} disabled={!nextConcept}>
{nextConcept ? `Next: ${nextConcept.title}` : "At final concept"}
</button>
<button onClick={onBack}>Back</button>
</div>
</header>
<main className="learner-grid">
<section className="card">
<p className="eyebrow">Onboarding</p>
<h2>{pack.onboarding.headline}</h2>
<p>{pack.onboarding.body}</p>
<div className="progress-strip">
<strong>Progress:</strong> {progress}% · {readiness?.mastered ?? 0}/{pack.concepts.length} concepts strongly supported
</div>
<ul className="plain-list">
{pack.onboarding.checklist.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
<section className="card">
<p className="eyebrow">Concept Path</p>
<div className="concept-path">
{pack.concepts.map((concept, index) => (
<button
key={concept.id}
className={`concept-chip${concept.id === currentConceptId ? " active" : ""}`}
onClick={() => setCurrentConceptId(concept.id)}
>
{concept.title}
<span className="chip-status">
{masteryMap[index]?.status || "locked"}
</span>
</button>
))}
</div>
<div className="concept-focus">
<h3>{currentConcept.title}</h3>
<div className="tiny">
Prerequisites: {currentConcept.prerequisites.length ? currentConcept.prerequisites.join(", ") : "none explicit"}
</div>
<p className="muted">{currentConcept.exerciseReward}</p>
</div>
</section>
<section className="card learner-form">
<p className="eyebrow">Current Study Record</p>
<h2>Keep observation and interpretation distinct.</h2>
<label>
Question
<textarea value={question} onChange={(e) => setQuestion(e.target.value)} placeholder="What are you trying to understand or test?" />
</label>
<label>
Observation
<textarea value={observation} onChange={(e) => setObservation(e.target.value)} placeholder="What did the model, species record, or source actually show?" />
</label>
<label>
Interpretation
<textarea value={interpretation} onChange={(e) => setInterpretation(e.target.value)} placeholder="What explanation currently fits best?" />
</label>
<label>
Uncertainty
<textarea value={uncertainty} onChange={(e) => setUncertainty(e.target.value)} placeholder="What remains unclear, weakly supported, or unresolved?" />
</label>
<label>
Revision trigger
<textarea value={revisionTrigger} onChange={(e) => setRevisionTrigger(e.target.value)} placeholder="What evidence or comparison would make you revise your current view?" />
</label>
<div className="toolbar">
<button className="primary" onClick={evaluateCurrentWork}>Evaluate this step</button>
</div>
</section>
<section className="card">
<p className="eyebrow">Virtues In Use</p>
<div className="virtue-grid">
<div className="virtue-card">
<strong>Curiosity</strong>
<p>Keep the question open long enough to learn from the evidence.</p>
</div>
<div className="virtue-card">
<strong>Honesty</strong>
<p>Write down what you actually observed before polishing an explanation.</p>
</div>
<div className="virtue-card">
<strong>Skepticism</strong>
<p>Ask whether the current claim is well supported or only convenient.</p>
</div>
<div className="virtue-card">
<strong>Revision</strong>
<p>Treat changed conclusions as progress, not as failure.</p>
</div>
</div>
</section>
<section className="card">
<p className="eyebrow">Evaluator Feedback</p>
<h2>Trust-preserving critique</h2>
{feedback ? (
<>
<div className="feedback-block">
<strong>Strengths</strong>
<ul className="plain-list">
{feedback.strengths.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
<div className="feedback-block">
<strong>Revision targets</strong>
<ul className="plain-list">
{feedback.gaps.length ? feedback.gaps.map((item) => <li key={item}>{item}</li>) : <li>No immediate gap detected; extend with another source comparison.</li>}
</ul>
</div>
<div className="compliance-note">
<strong>Next revision target:</strong> {feedback.nextRevision}
</div>
</>
) : (
<p className="muted">Submit a study record to get evaluator-style feedback grounded in the current concept and scientific-virtues framing.</p>
)}
</section>
<section className="card">
<p className="eyebrow">Backend Session Output</p>
<h2>Grounded mentor/practice/evaluator loop</h2>
{sessionOutput ? (
<div className="resource-list">
<div className="next-card">
<strong>Mentor</strong>
<p>{sessionOutput.mentor?.text}</p>
</div>
<div className="next-card">
<strong>Practice</strong>
<p>{sessionOutput.practice?.text}</p>
</div>
<div className="next-card">
<strong>Evaluator</strong>
<p>{sessionOutput.evaluator?.text}</p>
</div>
<div className="next-card">
<strong>Next step</strong>
<p>{sessionOutput.next_step?.text}</p>
</div>
</div>
) : (
<p className="muted">Run an evaluation to request grounded mentor, practice, evaluator, and next-step text from the backend session endpoint.</p>
)}
</section>
<section className="card">
<p className="eyebrow">Next Study Action</p>
<h2>{nextConcept ? nextConcept.title : "Complete the current concept carefully"}</h2>
<p>
{nextConcept
? `Move forward when you can justify your interpretation and name what evidence would change it. The next concept extends this work into ${nextConcept.title.toLowerCase()}.`
: "You are at the end of the current concept path. Review your uncertainty and revision trigger before treating the topic as settled."}
</p>
<div className="resource-list">
{nextCards.map((card) => (
<div className="next-card" key={card.id}>
<strong>{card.title}</strong>
<p>{card.reason}</p>
<div className="tiny">{card.why.join(" · ")}</div>
</div>
))}
</div>
<div className="feedback-block">
<strong>Milestones</strong>
<ul className="plain-list">
{milestones.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
<div className="compliance-note">
<strong>Source posture:</strong> {pack.compliance.sources} linked sources; attribution required: {pack.compliance.attributionRequired ? "yes" : "no"}.
</div>
</section>
</main>
</div>
);
}
export default function App() {
const [mode, setMode] = useState("launcher");
const [auth, setAuth] = useState(null);
if (mode === "learner") return <LearnerWorkbench onBack={() => setMode("launcher")} />;
if (mode === "review" && !auth) {
return <LoginView onAuth={setAuth} onBack={() => setMode("launcher")} />;
}
if (mode === "review" && auth) {
return <ReviewWorkbench auth={auth} onBack={() => { setAuth(null); setMode("launcher"); }} />;
}
return <LauncherView onSelect={setMode} />;
}

View File

@ -53,3 +53,13 @@ export async function promoteSynthesis(token, synthesisId, payload) {
if (!res.ok) throw new Error("promoteSynthesis failed");
return await res.json();
}
export async function createLearnerWorkbenchSession(payload) {
const res = await fetch(`${API}/learner-workbench/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("createLearnerWorkbenchSession failed");
return await res.json();
}

View File

@ -10,8 +10,10 @@ body { margin:0; background:var(--bg); color:var(--text); font-family:Arial, Hel
.grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.stack { display:grid; gap:14px; }
.card.small h3 { margin-top:0; }
.eyebrow { margin:0 0 8px; font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--accent); }
label { display:block; font-weight:600; margin-bottom:10px; }
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
input, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
textarea { min-height:108px; resize:vertical; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 12px; cursor:pointer; margin-right:8px; margin-top:8px; }
button.primary { background:var(--accent); color:white; border-color:var(--accent); }
.actions { display:flex; flex-wrap:wrap; gap:8px; }
@ -19,7 +21,26 @@ button.primary { background:var(--accent); color:white; border-color:var(--accen
.muted { color:var(--muted); }
.tiny { font-size:12px; color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
.plain-list { margin:0; padding-left:18px; }
.learner-page { max-width:1600px; }
.learner-grid { display:grid; grid-template-columns:1.1fr .9fr; gap:18px; }
.learner-hero { background:linear-gradient(135deg, #eef4ff, #ffffff); }
.concept-path { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
.concept-chip { margin:0; border-radius:16px; display:flex; flex-direction:column; gap:4px; align-items:flex-start; }
.concept-chip.active { background:var(--accent); color:white; border-color:var(--accent); }
.chip-status { font-size:11px; text-transform:uppercase; letter-spacing:.05em; opacity:.8; }
.concept-focus { border-top:1px solid var(--border); padding-top:12px; }
.learner-form { grid-row:span 2; }
.virtue-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.virtue-card { border:1px solid var(--border); border-radius:14px; padding:14px; background:#fbfcfe; }
.compliance-note { margin-top:10px; padding:10px 12px; border-radius:12px; background:#f3f6fb; color:var(--muted); }
.progress-strip { margin:10px 0 14px; padding:10px 12px; border-radius:12px; background:#eef4ff; color:#214d9b; }
.feedback-block { margin-top:12px; padding:12px 14px; border:1px solid var(--border); border-radius:14px; background:#fbfcfe; }
.resource-list { display:grid; gap:10px; margin-top:12px; }
.next-card { border:1px solid var(--border); border-radius:14px; padding:12px 14px; background:#fbfcfe; }
@media (max-width: 1100px) {
.grid { grid-template-columns:1fr; }
.hero { flex-direction:column; }
.learner-grid { grid-template-columns:1fr; }
.virtue-grid { grid-template-columns:1fr; }
}