diff --git a/docs/evo-edu-evidence-trail-pilot.md b/docs/evo-edu-evidence-trail-pilot.md new file mode 100755 index 0000000..b52f463 --- /dev/null +++ b/docs/evo-edu-evidence-trail-pilot.md @@ -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. diff --git a/domain-packs/evidence-trail/concepts.yaml b/domain-packs/evidence-trail/concepts.yaml new file mode 100755 index 0000000..833970a --- /dev/null +++ b/domain-packs/evidence-trail/concepts.yaml @@ -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: {} diff --git a/domain-packs/evidence-trail/pack.frontend.json b/domain-packs/evidence-trail/pack.frontend.json new file mode 100755 index 0000000..ae02418 --- /dev/null +++ b/domain-packs/evidence-trail/pack.frontend.json @@ -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" + ] + } +} \ No newline at end of file diff --git a/domain-packs/evidence-trail/pack.yaml b/domain-packs/evidence-trail/pack.yaml new file mode 100755 index 0000000..95e52af --- /dev/null +++ b/domain-packs/evidence-trail/pack.yaml @@ -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. diff --git a/domain-packs/evidence-trail/pack_compliance_manifest.json b/domain-packs/evidence-trail/pack_compliance_manifest.json new file mode 100755 index 0000000..beb4942 --- /dev/null +++ b/domain-packs/evidence-trail/pack_compliance_manifest.json @@ -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/" + } + ] +} diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 1798efa..7ae2860 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -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) diff --git a/src/didactopus/learner_session.py b/src/didactopus/learner_session.py index af0a7ac..090f8e0 100644 --- a/src/didactopus/learner_session.py +++ b/src/didactopus/learner_session.py @@ -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." + "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, diff --git a/src/didactopus/learner_workbench.py b/src/didactopus/learner_workbench.py new file mode 100755 index 0000000..5bc1a32 --- /dev/null +++ b/src/didactopus/learner_workbench.py @@ -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, + } diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 16f12be..a92e24c 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -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 = "" diff --git a/src/didactopus/role_prompts.py b/src/didactopus/role_prompts.py index 775780b..a0d04bc 100644 --- a/src/didactopus/role_prompts.py +++ b/src/didactopus/role_prompts.py @@ -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.", }, @@ -37,14 +44,16 @@ def mentor_system_prompt() -> str: "You are Didactopus in mentor mode. Help the learner think through the topic without doing the work for them. " "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." + "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." ) @@ -68,7 +77,8 @@ def evaluator_system_prompt() -> str: "Point out weak assumptions and missing justification instead of giving the polished final answer. " "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." + "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." ) diff --git a/tests/test_api_scaffold.py b/tests/test_api_scaffold.py index d4c4848..3bcfed3 100644 --- a/tests/test_api_scaffold.py +++ b/tests/test_api_scaffold.py @@ -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() diff --git a/tests/test_frontend_files.py b/tests/test_frontend_files.py index 9048037..6ae372e 100644 --- a/tests/test_frontend_files.py +++ b/tests/test_frontend_files.py @@ -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() diff --git a/tests/test_learner_workbench.py b/tests/test_learner_workbench.py new file mode 100755 index 0000000..59070f0 --- /dev/null +++ b/tests/test_learner_workbench.py @@ -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"] == [] diff --git a/tests/test_ui_files.py b/tests/test_ui_files.py index 7a06f82..69280c5 100644 --- a/tests/test_ui_files.py +++ b/tests/test_ui_files.py @@ -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() diff --git a/webui/public/packs/evidence-trail-pack.json b/webui/public/packs/evidence-trail-pack.json new file mode 100755 index 0000000..ae02418 --- /dev/null +++ b/webui/public/packs/evidence-trail-pack.json @@ -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" + ] + } +} \ No newline at end of file diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 2ac4da7..53f18ab 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -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 ( +
+
+
+

Didactopus

+

Choose a work mode.

+

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

+
+
+ + +
+
+ +
+
+

Learner workbench pilot

+

+ Use a guided-study surface built around question framing, source comparison, and + revision under uncertainty. +

+
    +
  • Loads the `Evidence Trail` pack.
  • +
  • Shows concept path and onboarding.
  • +
  • Separates observation from interpretation.
  • +
  • Treats revision as normal progress.
  • +
+
+
+

Review workbench

+

+ Keep using the existing knowledge-candidate and synthesis workflow for pack-improvement + and review operations. +

+
    +
  • Review learner-derived candidates.
  • +
  • Promote pack improvements and skill bundles.
  • +
  • Inspect synthesis candidates across packs.
  • +
+
+
+
+ ); +} + +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 (
+

Review Workbench

Didactopus review workbench

- - - + + +
+ + +
{error ?
{error}
: null}
@@ -30,9 +109,13 @@ function CandidateCard({ candidate, onReview, onPromote }) { return (

{candidate.title}

-
{candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status}
+
+ {candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status} +

{candidate.summary}

-
confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)}
+
+ confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)} +
@@ -49,14 +132,15 @@ function SynthesisCard({ item, onPromote }) {

{item.source_concept_id} ↔ {item.target_concept_id}

{item.source_pack_id} → {item.target_pack_id}

{item.explanation}

-
total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)}
+
+ total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)} +
); } -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 ; - return (
+

Review Workbench

Review workbench + synthesis engine

-

Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles, or archive, while surfacing cross-pack synthesis proposals.

+

+ Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles, + or archive, while surfacing cross-pack synthesis proposals. +

{message}
- + +
@@ -165,3 +250,356 @@ export default function App() {
); } + +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 ( +
+
+

Learner workbench pilot

+
{error}
+ +
+
+ ); + } + + if (!pack || !currentConcept) { + return ( +
+
+

Learner workbench pilot

+

Loading the `Evidence Trail` pack...

+
+
+ ); + } + + return ( +
+
+
+

Learner Workbench Pilot

+

{pack.title}

+

{pack.subtitle}

+
+ This pilot uses scientific virtues as operating rules: separate observation from interpretation, + preserve uncertainty, and treat revision as progress. +
+
+
+ + +
+
+ +
+
+

Onboarding

+

{pack.onboarding.headline}

+

{pack.onboarding.body}

+
+ Progress: {progress}% · {readiness?.mastered ?? 0}/{pack.concepts.length} concepts strongly supported +
+
    + {pack.onboarding.checklist.map((item) => ( +
  • {item}
  • + ))} +
+
+ +
+

Concept Path

+
+ {pack.concepts.map((concept, index) => ( + + ))} +
+
+

{currentConcept.title}

+
+ Prerequisites: {currentConcept.prerequisites.length ? currentConcept.prerequisites.join(", ") : "none explicit"} +
+

{currentConcept.exerciseReward}

+
+
+ +
+

Current Study Record

+

Keep observation and interpretation distinct.

+