diff --git a/bad-generated-pack/concepts.yaml b/bad-generated-pack/concepts.yaml index 589ac4f..a9c483b 100644 --- a/bad-generated-pack/concepts.yaml +++ b/bad-generated-pack/concepts.yaml @@ -1,11 +1,33 @@ concepts: - - id: c1 + - id: a title: Foundations - description: Broad foundations topic. - mastery_signals: - - Explain core foundations clearly. - - id: c2 + description: Introductory foundations concept with broad scope. + prerequisites: [c] + - id: b + title: Advanced Analysis + description: Advanced analysis of results in a difficult domain. + prerequisites: [a] + - id: c title: Methods - description: Methods topic. - mastery_signals: - - Use methods appropriately. + description: Methods and procedures for analysis in context. + prerequisites: [b] + - id: isolated + title: Isolated Topic + description: A topic disconnected from the rest of the graph. + prerequisites: [] + - id: bottleneck + title: Core Bottleneck + description: Central concept required by many dependents in the pack. + prerequisites: [] + - id: d1 + title: Dependent One + description: Depends on a single core bottleneck concept. + prerequisites: [bottleneck] + - id: d2 + title: Dependent Two + description: Depends on a single core bottleneck concept. + prerequisites: [bottleneck] + - id: d3 + title: Dependent Three + description: Depends on a single core bottleneck concept. + prerequisites: [bottleneck] diff --git a/bad-generated-pack/projects.yaml b/bad-generated-pack/projects.yaml index 761d0a4..4d053f9 100644 --- a/bad-generated-pack/projects.yaml +++ b/bad-generated-pack/projects.yaml @@ -1,6 +1 @@ -projects: - - id: p1 - title: Final Memo - prerequisites: [c1] - deliverables: - - brief memo +projects: [] diff --git a/bad-generated-pack/roadmap.yaml b/bad-generated-pack/roadmap.yaml index a9d28dc..0b378df 100644 --- a/bad-generated-pack/roadmap.yaml +++ b/bad-generated-pack/roadmap.yaml @@ -1,6 +1,13 @@ stages: - id: stage-1 - title: Start - concepts: [c1, c2] - checkpoint: - - oral discussion + title: Foundations + concepts: [a, bottleneck] + - id: stage-2 + title: Advanced Analysis + concepts: [b, d1] + - id: stage-3 + title: Methods + concepts: [c, d2, d3] + - id: stage-4 + title: Detached Topic + concepts: [isolated] diff --git a/bad-generated-pack/rubrics.yaml b/bad-generated-pack/rubrics.yaml index 4411647..14571f5 100644 --- a/bad-generated-pack/rubrics.yaml +++ b/bad-generated-pack/rubrics.yaml @@ -1,4 +1 @@ -rubrics: - - id: r1 - title: Basic - criteria: [style, formatting] +rubrics: [] diff --git a/docs/faq.md b/docs/faq.md index aecd8ae..d5ff140 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,9 +1,24 @@ # FAQ -## Why add curriculum path quality? +## Why add graph-aware analysis? -Because even a good concept graph does not automatically produce a good learning path. +Because Didactopus is fundamentally concerned with mastery paths and dependency structure. +A pack can look fine in text form and still behave badly as a learning graph. -## Does this replace human pedagogical judgment? +## What kinds of issues can it find? -No. It is a heuristic review aid meant to reduce preventable friction. +Examples: +- cycles +- isolated concepts +- dependency bottlenecks +- packs that are too flat +- packs that are too deep + +## How does this help with the activation-energy problem? + +It reduces the chance that a user imports a pack and only later discovers that the +prerequisite structure is confusing or fragile. + +## Does this replace human review? + +No. It gives the reviewer better signals earlier. diff --git a/generated-pack/concepts.yaml b/generated-pack/concepts.yaml index 61268f4..c7635ba 100644 --- a/generated-pack/concepts.yaml +++ b/generated-pack/concepts.yaml @@ -2,10 +2,14 @@ concepts: - id: bayes-prior title: Bayes Prior description: Prior beliefs before evidence in a probabilistic model. - mastery_signals: - - Explain a prior distribution clearly. + prerequisites: [] - id: bayes-posterior title: Bayes Posterior description: Updated beliefs after evidence in a probabilistic model. - mastery_signals: - - Compare prior and posterior beliefs. + prerequisites: + - bayes-prior + - id: model-checking + title: Model Checking + description: Evaluate whether model assumptions and fit remain plausible. + prerequisites: + - bayes-posterior diff --git a/generated-pack/projects.yaml b/generated-pack/projects.yaml index 64a6918..4d053f9 100644 --- a/generated-pack/projects.yaml +++ b/generated-pack/projects.yaml @@ -1,7 +1 @@ -projects: - - id: p1 - title: Final Bayesian Comparison - prerequisites: [bayes-prior, bayes-posterior] - deliverables: - - explanation - - comparison report +projects: [] diff --git a/generated-pack/roadmap.yaml b/generated-pack/roadmap.yaml index f6e7ba9..d196c42 100644 --- a/generated-pack/roadmap.yaml +++ b/generated-pack/roadmap.yaml @@ -2,10 +2,9 @@ stages: - id: stage-1 title: Prior Beliefs concepts: [bayes-prior] - checkpoint: - - explanation exercise on prior distribution - id: stage-2 title: Posterior Updating concepts: [bayes-posterior] - checkpoint: - - comparison exercise on prior and posterior beliefs + - id: stage-3 + title: Model Checking + concepts: [model-checking] diff --git a/generated-pack/rubrics.yaml b/generated-pack/rubrics.yaml index cd4006e..14571f5 100644 --- a/generated-pack/rubrics.yaml +++ b/generated-pack/rubrics.yaml @@ -1,4 +1 @@ -rubrics: - - id: r1 - title: Basic - criteria: [correctness, explanation] +rubrics: [] diff --git a/pyproject.toml b/pyproject.toml index 1fcb487..054ecf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,18 @@ build-backend = "setuptools.build_meta" [project] name = "didactopus" version = "0.1.0" +description = "Didactopus: graph-aware prerequisite analysis" +readme = "README.md" requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "Wesley R. Elsberry"}] dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.6"] + +[project.scripts] +didactopus-review-bridge = "didactopus.review_bridge_server:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/__init__.py b/src/didactopus/__init__.py index b794fd4..3dc1f76 100644 --- a/src/didactopus/__init__.py +++ b/src/didactopus/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' +__version__ = "0.1.0" diff --git a/src/didactopus/graph_qa.py b/src/didactopus/graph_qa.py index 4f715ce..eed1bf0 100644 --- a/src/didactopus/graph_qa.py +++ b/src/didactopus/graph_qa.py @@ -1,2 +1,91 @@ -def graph_qa_for_pack(source_dir): - return {'warnings': [], 'summary': {'graph_warning_count': 0}} +from __future__ import annotations +from collections import defaultdict, deque +from .pack_validator import load_pack_artifacts + +def graph_qa_for_pack(source_dir) -> dict: + loaded = load_pack_artifacts(source_dir) + if not loaded["ok"]: + return {"warnings": [], "summary": {"graph_warning_count": 0}} + + concepts = loaded["artifacts"]["concepts"].get("concepts", []) or [] + concept_ids = [c.get("id") for c in concepts if c.get("id")] + prereqs = {c.get("id"): list(c.get("prerequisites", []) or []) for c in concepts if c.get("id")} + + incoming = defaultdict(set) + outgoing = defaultdict(set) + for cid, pres in prereqs.items(): + for p in pres: + outgoing[p].add(cid) + incoming[cid].add(p) + + warnings = [] + + # Cycle detection + WHITE, GRAY, BLACK = 0, 1, 2 + color = {cid: WHITE for cid in concept_ids} + stack = [] + found_cycles = [] + + def dfs(node): + color[node] = GRAY + stack.append(node) + for nxt in outgoing.get(node, []): + if color.get(nxt, WHITE) == WHITE: + dfs(nxt) + elif color.get(nxt) == GRAY: + if nxt in stack: + idx = stack.index(nxt) + found_cycles.append(stack[idx:] + [nxt]) + stack.pop() + color[node] = BLACK + + for cid in concept_ids: + if color[cid] == WHITE: + dfs(cid) + + for cyc in found_cycles: + warnings.append("Prerequisite cycle detected: " + " -> ".join(cyc)) + + # Isolated concepts + for cid in concept_ids: + if len(incoming[cid]) == 0 and len(outgoing[cid]) == 0: + warnings.append(f"Concept '{cid}' is isolated from the prerequisite graph.") + + # Bottlenecks + threshold = 3 + for cid in concept_ids: + if len(outgoing[cid]) >= threshold: + warnings.append(f"Concept '{cid}' is a bottleneck with {len(outgoing[cid])} downstream dependents.") + + # Flatness + edge_count = sum(len(v) for v in prereqs.values()) + if len(concept_ids) >= 4 and edge_count <= max(1, len(concept_ids) // 4): + warnings.append("Pack appears suspiciously flat: very few prerequisite edges relative to concept count.") + + # Deep chains + indegree = {cid: len(incoming[cid]) for cid in concept_ids} + q = deque([cid for cid in concept_ids if indegree[cid] == 0]) + longest = {cid: 1 for cid in concept_ids} + visited = 0 + while q: + node = q.popleft() + visited += 1 + for nxt in outgoing.get(node, []): + longest[nxt] = max(longest.get(nxt, 1), longest[node] + 1) + indegree[nxt] -= 1 + if indegree[nxt] == 0: + q.append(nxt) + + max_chain = max(longest.values()) if longest else 0 + if max_chain >= 6: + warnings.append(f"Pack has a deep prerequisite chain of length {max_chain}, which may indicate over-fragmentation.") + + summary = { + "graph_warning_count": len(warnings), + "concept_count": len(concept_ids), + "edge_count": edge_count, + "max_chain_length": max_chain, + "cycle_count": len(found_cycles), + "isolated_count": sum(1 for cid in concept_ids if len(incoming[cid]) == 0 and len(outgoing[cid]) == 0), + } + return {"warnings": warnings, "summary": summary} diff --git a/src/didactopus/import_validator.py b/src/didactopus/import_validator.py index a97c874..5b60934 100644 --- a/src/didactopus/import_validator.py +++ b/src/didactopus/import_validator.py @@ -1,22 +1,15 @@ +from __future__ import annotations from pathlib import Path from .review_schema import ImportPreview from .pack_validator import validate_pack_directory from .semantic_qa import semantic_qa_for_pack from .graph_qa import graph_qa_for_pack -from .path_quality_qa import path_quality_for_pack -from .coverage_alignment_qa import coverage_alignment_for_pack -from .evaluator_alignment_qa import evaluator_alignment_for_pack -from .evidence_flow_ledger_qa import evidence_flow_ledger_for_pack -def preview_draft_pack_import(source_dir, workspace_id, overwrite_required=False): +def preview_draft_pack_import(source_dir: str | Path, workspace_id: str, overwrite_required: bool = False) -> ImportPreview: result = validate_pack_directory(source_dir) - semantic = semantic_qa_for_pack(source_dir) if result["ok"] else {"warnings": []} - graph = graph_qa_for_pack(source_dir) if result["ok"] else {"warnings": []} - pathq = path_quality_for_pack(source_dir) if result["ok"] else {"warnings": []} - coverage = coverage_alignment_for_pack(source_dir) if result["ok"] else {"warnings": []} - evaluator = evaluator_alignment_for_pack(source_dir) if result["ok"] else {"warnings": []} - ledger = evidence_flow_ledger_for_pack(source_dir) if result["ok"] else {"warnings": []} - return ImportPreview( + semantic = semantic_qa_for_pack(source_dir) if result["ok"] else {"warnings": [], "summary": {}} + graph = graph_qa_for_pack(source_dir) if result["ok"] else {"warnings": [], "summary": {}} + preview = ImportPreview( source_dir=str(Path(source_dir)), workspace_id=workspace_id, overwrite_required=overwrite_required, @@ -26,8 +19,5 @@ def preview_draft_pack_import(source_dir, workspace_id, overwrite_required=False summary=dict(result["summary"]), semantic_warnings=list(semantic["warnings"]), graph_warnings=list(graph["warnings"]), - path_warnings=list(pathq["warnings"]), - coverage_warnings=list(coverage["warnings"]), - evaluator_warnings=list(evaluator["warnings"]), - ledger_warnings=list(ledger["warnings"]), ) + return preview diff --git a/src/didactopus/pack_validator.py b/src/didactopus/pack_validator.py index 6dae0a1..763204f 100644 --- a/src/didactopus/pack_validator.py +++ b/src/didactopus/pack_validator.py @@ -1,45 +1,141 @@ +from __future__ import annotations from pathlib import Path import yaml -REQUIRED_FILES = ["pack.yaml","concepts.yaml","roadmap.yaml","projects.yaml","rubrics.yaml","evaluator.yaml","mastery_ledger.yaml"] +REQUIRED_FILES = ["pack.yaml", "concepts.yaml", "roadmap.yaml", "projects.yaml", "rubrics.yaml"] -def _load(path: Path, errors: list[str], label: str): +def _safe_load_yaml(path: Path, errors: list[str], label: str): try: return yaml.safe_load(path.read_text(encoding="utf-8")) or {} except Exception as exc: errors.append(f"Could not parse {label}: {exc}") return {} -def load_pack_artifacts(source_dir): +def load_pack_artifacts(source_dir: str | Path) -> dict: source = Path(source_dir) - errors = [] + errors: list[str] = [] if not source.exists(): - return {"ok": False, "errors": [f"Source directory does not exist: {source}"], "artifacts": {}} + return {"ok": False, "errors": [f"Source directory does not exist: {source}"], "warnings": [], "summary": {}, "artifacts": {}} if not source.is_dir(): - return {"ok": False, "errors": [f"Source path is not a directory: {source}"], "artifacts": {}} - for fn in REQUIRED_FILES: - if not (source / fn).exists(): - errors.append(f"Missing required file: {fn}") - if errors: - return {"ok": False, "errors": errors, "artifacts": {}} - arts = {} - for stem in ["pack","concepts","roadmap","projects","rubrics","evaluator","mastery_ledger"]: - arts[stem] = _load(source / f"{stem}.yaml", errors, f"{stem}.yaml") - return {"ok": len(errors) == 0, "errors": errors, "artifacts": arts} + return {"ok": False, "errors": [f"Source path is not a directory: {source}"], "warnings": [], "summary": {}, "artifacts": {}} -def validate_pack_directory(source_dir): - loaded = load_pack_artifacts(source_dir) - if not loaded["ok"]: - return {"ok": False, "errors": loaded["errors"], "warnings": [], "summary": {}} - arts = loaded["artifacts"] - concepts = arts["concepts"].get("concepts", []) or [] - dims = arts["evaluator"].get("dimensions", []) or [] - summary = { - "pack_name": arts["pack"].get("name", ""), - "display_name": arts["pack"].get("display_name", ""), - "version": arts["pack"].get("version", ""), - "concept_count": len(concepts), - "evaluator_dimension_count": len(dims), - "ledger_field_count": len((arts["mastery_ledger"].get("entry_schema", {}) or {}).keys()), + for filename in REQUIRED_FILES: + if not (source / filename).exists(): + errors.append(f"Missing required file: {filename}") + if errors: + return {"ok": False, "errors": errors, "warnings": [], "summary": {}, "artifacts": {}} + + pack_data = _safe_load_yaml(source / "pack.yaml", errors, "pack.yaml") + concepts_data = _safe_load_yaml(source / "concepts.yaml", errors, "concepts.yaml") + roadmap_data = _safe_load_yaml(source / "roadmap.yaml", errors, "roadmap.yaml") + projects_data = _safe_load_yaml(source / "projects.yaml", errors, "projects.yaml") + rubrics_data = _safe_load_yaml(source / "rubrics.yaml", errors, "rubrics.yaml") + return { + "ok": len(errors) == 0, + "errors": errors, + "warnings": [], + "summary": {}, + "artifacts": { + "pack": pack_data, + "concepts": concepts_data, + "roadmap": roadmap_data, + "projects": projects_data, + "rubrics": rubrics_data, + }, } - return {"ok": True, "errors": [], "warnings": [], "summary": summary} + +def validate_pack_directory(source_dir: str | Path) -> dict: + loaded = load_pack_artifacts(source_dir) + errors = list(loaded["errors"]) + warnings = list(loaded["warnings"]) + summary = dict(loaded["summary"]) + if not loaded["ok"]: + return {"ok": False, "errors": errors, "warnings": warnings, "summary": summary} + + pack_data = loaded["artifacts"]["pack"] + concepts_data = loaded["artifacts"]["concepts"] + roadmap_data = loaded["artifacts"]["roadmap"] + projects_data = loaded["artifacts"]["projects"] + rubrics_data = loaded["artifacts"]["rubrics"] + + for field in ["name", "display_name", "version"]: + if field not in pack_data: + warnings.append(f"pack.yaml has no '{field}' field.") + + concepts = concepts_data.get("concepts", []) + roadmap_stages = roadmap_data.get("stages", []) + projects = projects_data.get("projects", []) + rubrics = rubrics_data.get("rubrics", []) + + if not isinstance(concepts, list): + errors.append("concepts.yaml top-level 'concepts' is not a list.") + concepts = [] + if not isinstance(roadmap_stages, list): + errors.append("roadmap.yaml top-level 'stages' is not a list.") + roadmap_stages = [] + if not isinstance(projects, list): + errors.append("projects.yaml top-level 'projects' is not a list.") + projects = [] + if not isinstance(rubrics, list): + errors.append("rubrics.yaml top-level 'rubrics' is not a list.") + rubrics = [] + + concept_ids = [] + for idx, concept in enumerate(concepts): + cid = concept.get("id", "") + if not cid: + errors.append(f"Concept at index {idx} has no id.") + else: + concept_ids.append(cid) + if not concept.get("title"): + warnings.append(f"Concept '{cid or idx}' has no title.") + desc = str(concept.get("description", "") or "") + if len(desc.strip()) < 12: + warnings.append(f"Concept '{cid or idx}' has a very thin description.") + + seen = set() + dups = set() + for cid in concept_ids: + if cid in seen: + dups.add(cid) + seen.add(cid) + for cid in sorted(dups): + errors.append(f"Duplicate concept id: {cid}") + + concept_id_set = set(concept_ids) + + for stage in roadmap_stages: + for cid in stage.get("concepts", []) or []: + if cid not in concept_id_set: + errors.append(f"roadmap.yaml references missing concept id: {cid}") + + for project in projects: + if not project.get("id"): + warnings.append("A project entry has no id.") + for cid in project.get("prerequisites", []) or []: + if cid not in concept_id_set: + errors.append(f"projects.yaml references missing prerequisite concept id: {cid}") + + for idx, rubric in enumerate(rubrics): + if not rubric.get("id"): + warnings.append(f"Rubric at index {idx} has no id.") + criteria = rubric.get("criteria", []) + if criteria is None: + warnings.append(f"Rubric '{rubric.get('id', idx)}' has null criteria.") + elif isinstance(criteria, list) and len(criteria) == 0: + warnings.append(f"Rubric '{rubric.get('id', idx)}' has empty criteria.") + elif not isinstance(criteria, list): + errors.append(f"Rubric '{rubric.get('id', idx)}' criteria is not a list.") + + summary = { + "pack_name": pack_data.get("name", ""), + "display_name": pack_data.get("display_name", ""), + "version": pack_data.get("version", ""), + "concept_count": len(concepts), + "roadmap_stage_count": len(roadmap_stages), + "project_count": len(projects), + "rubric_count": len(rubrics), + "error_count": len(errors), + "warning_count": len(warnings), + } + return {"ok": len(errors) == 0, "errors": errors, "warnings": warnings, "summary": summary} diff --git a/src/didactopus/review_bridge.py b/src/didactopus/review_bridge.py index ca3adca..5d165a9 100644 --- a/src/didactopus/review_bridge.py +++ b/src/didactopus/review_bridge.py @@ -8,26 +8,43 @@ from .review_export import export_review_state_json, export_promoted_pack class ReviewWorkspaceBridge: def __init__(self, workspace_dir: str | Path, reviewer: str = "Unknown Reviewer") -> None: - self.workspace_dir = Path(workspace_dir); self.reviewer = reviewer; self.workspace_dir.mkdir(parents=True, exist_ok=True) + self.workspace_dir = Path(workspace_dir) + self.reviewer = reviewer + self.workspace_dir.mkdir(parents=True, exist_ok=True) + @property - def draft_pack_dir(self) -> Path: return self.workspace_dir / "draft_pack" + def draft_pack_dir(self) -> Path: + return self.workspace_dir / "draft_pack" + @property - def review_session_path(self) -> Path: return self.workspace_dir / "review_session.json" + def review_session_path(self) -> Path: + return self.workspace_dir / "review_session.json" + @property - def promoted_pack_dir(self) -> Path: return self.workspace_dir / "promoted_pack" + def promoted_pack_dir(self) -> Path: + return self.workspace_dir / "promoted_pack" + def load_session(self) -> ReviewSession: if self.review_session_path.exists(): - return ReviewSession.model_validate(json.loads(self.review_session_path.read_text(encoding="utf-8"))) + data = json.loads(self.review_session_path.read_text(encoding="utf-8")) + return ReviewSession.model_validate(data) draft = load_draft_pack(self.draft_pack_dir) session = ReviewSession(reviewer=self.reviewer, draft_pack=draft) export_review_state_json(session, self.review_session_path) return session + def save_session(self, session: ReviewSession) -> None: export_review_state_json(session, self.review_session_path) + def apply_actions(self, actions: list[dict]) -> ReviewSession: session = self.load_session() for action_dict in actions: - apply_action(session, session.reviewer, ReviewAction.model_validate(action_dict)) - self.save_session(session); return session + action = ReviewAction.model_validate(action_dict) + apply_action(session, session.reviewer, action) + self.save_session(session) + return session + def export_promoted(self) -> ReviewSession: - session = self.load_session(); export_promoted_pack(session, self.promoted_pack_dir); return session + session = self.load_session() + export_promoted_pack(session, self.promoted_pack_dir) + return session diff --git a/src/didactopus/review_bridge_server.py b/src/didactopus/review_bridge_server.py index 72d09d9..ac00de8 100644 --- a/src/didactopus/review_bridge_server.py +++ b/src/didactopus/review_bridge_server.py @@ -1,6 +1,7 @@ from __future__ import annotations -import argparse, json +import argparse from http.server import BaseHTTPRequestHandler, HTTPServer +import json from pathlib import Path from .config import load_config from .review_bridge import ReviewWorkspaceBridge @@ -26,55 +27,100 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler): @classmethod def set_active_workspace(cls, workspace_id: str) -> bool: meta = cls.workspace_manager.touch_recent(workspace_id) - if meta is None: return False + if meta is None: + return False cls.active_workspace_id = workspace_id cls.active_bridge = ReviewWorkspaceBridge(meta.path, reviewer=cls.reviewer) return True - def do_OPTIONS(self): json_response(self, 200, {"ok": True}) + def do_OPTIONS(self): + json_response(self, 200, {"ok": True}) def do_GET(self): if self.path == "/api/workspaces": - return json_response(self, 200, ReviewBridgeHandler.workspace_manager.list_workspaces().model_dump()) + reg = self.workspace_manager.list_workspaces() + json_response(self, 200, reg.model_dump()) + return if self.path == "/api/load": - if self.active_bridge is None: return json_response(self, 400, {"error": "no active workspace"}) - return json_response(self, 200, {"workspace_id": self.active_workspace_id, "session": self.active_bridge.load_session().model_dump()}) - return json_response(self, 404, {"error": "not found"}) + if self.active_bridge is None: + json_response(self, 400, {"error": "no active workspace"}) + return + session = self.active_bridge.load_session() + json_response(self, 200, {"workspace_id": self.active_workspace_id, "session": session.model_dump()}) + return + json_response(self, 404, {"error": "not found"}) def do_POST(self): length = int(self.headers.get("Content-Length", "0")) - payload = json.loads((self.rfile.read(length) if length else b"{}").decode("utf-8") or "{}") + raw = self.rfile.read(length) if length else b"{}" + payload = json.loads(raw.decode("utf-8") or "{}") + if self.path == "/api/workspaces/create": - meta = self.workspace_manager.create_workspace(payload["workspace_id"], payload["title"], notes=payload.get("notes", "")) - self.set_active_workspace(meta.workspace_id); return json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) + meta = self.workspace_manager.create_workspace( + workspace_id=payload["workspace_id"], + title=payload["title"], + notes=payload.get("notes", "") + ) + self.set_active_workspace(meta.workspace_id) + json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) + return + if self.path == "/api/workspaces/open": ok = self.set_active_workspace(payload["workspace_id"]) - return json_response(self, 200 if ok else 404, {"ok": ok, "workspace_id": self.active_workspace_id} if ok else {"error": "workspace not found"}) + if not ok: + json_response(self, 404, {"error": "workspace not found"}) + return + json_response(self, 200, {"ok": True, "workspace_id": self.active_workspace_id}) + return + if self.path == "/api/workspaces/import-preview": - preview = self.workspace_manager.preview_import(payload["source_dir"], payload["workspace_id"]) - return json_response(self, 200, preview.model_dump()) + preview = self.workspace_manager.preview_import( + source_dir=payload["source_dir"], + workspace_id=payload["workspace_id"] + ) + json_response(self, 200, preview.model_dump()) + return + if self.path == "/api/workspaces/import": try: - meta = self.workspace_manager.import_draft_pack(payload["source_dir"], payload["workspace_id"], title=payload.get("title"), notes=payload.get("notes", ""), allow_overwrite=bool(payload.get("allow_overwrite", False))) + meta = self.workspace_manager.import_draft_pack( + source_dir=payload["source_dir"], + workspace_id=payload["workspace_id"], + title=payload.get("title"), + notes=payload.get("notes", ""), + allow_overwrite=bool(payload.get("allow_overwrite", False)), + ) except FileNotFoundError as exc: - return json_response(self, 404, {"ok": False, "error": str(exc)}) + json_response(self, 404, {"ok": False, "error": str(exc)}) + return except FileExistsError as exc: - return json_response(self, 409, {"ok": False, "error": str(exc)}) + json_response(self, 409, {"ok": False, "error": str(exc)}) + return except ValueError as exc: - return json_response(self, 400, {"ok": False, "error": str(exc)}) + json_response(self, 400, {"ok": False, "error": str(exc)}) + return self.set_active_workspace(meta.workspace_id) - return json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) + json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) + return + if self.active_bridge is None: - return json_response(self, 400, {"error": "no active workspace"}) + json_response(self, 400, {"error": "no active workspace"}) + return + if self.path == "/api/save": - return json_response(self, 200, {"ok": True, "workspace_id": self.active_workspace_id, "session": self.active_bridge.apply_actions(payload.get("actions", [])).model_dump()}) + session = self.active_bridge.apply_actions(payload.get("actions", [])) + json_response(self, 200, {"ok": True, "workspace_id": self.active_workspace_id, "session": session.model_dump()}) + return + if self.path == "/api/export": session = self.active_bridge.export_promoted() - return json_response(self, 200, {"ok": True, "promoted_pack_dir": str(self.active_bridge.promoted_pack_dir), "workspace_id": self.active_workspace_id, "session": session.model_dump()}) - return json_response(self, 404, {"error": "not found"}) + json_response(self, 200, {"ok": True, "promoted_pack_dir": str(self.active_bridge.promoted_pack_dir), "workspace_id": self.active_workspace_id, "session": session.model_dump()}) + return + + json_response(self, 404, {"error": "not found"}) def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Didactopus local review bridge server with curriculum path quality QA") + parser = argparse.ArgumentParser(description="Didactopus local review bridge server with graph QA") parser.add_argument("--config", default="configs/config.example.yaml") return parser @@ -82,7 +128,13 @@ def main() -> None: args = build_parser().parse_args() config = load_config(Path(args.config)) ReviewBridgeHandler.reviewer = config.review.default_reviewer - ReviewBridgeHandler.workspace_manager = WorkspaceManager(config.bridge.registry_path, config.bridge.default_workspace_root) + ReviewBridgeHandler.workspace_manager = WorkspaceManager( + registry_path=config.bridge.registry_path, + default_workspace_root=config.bridge.default_workspace_root + ) server = HTTPServer((config.bridge.host, config.bridge.port), ReviewBridgeHandler) print(f"Didactopus review bridge listening on http://{config.bridge.host}:{config.bridge.port}") server.serve_forever() + +if __name__ == "__main__": + main() diff --git a/src/didactopus/review_schema.py b/src/didactopus/review_schema.py index bdeab42..e02ca0e 100644 --- a/src/didactopus/review_schema.py +++ b/src/didactopus/review_schema.py @@ -1,4 +1,51 @@ +from __future__ import annotations from pydantic import BaseModel, Field +from typing import Literal + +TrustStatus = Literal["trusted", "provisional", "rejected", "needs_review"] + +class ConceptReviewEntry(BaseModel): + concept_id: str + title: str + description: str = "" + prerequisites: list[str] = Field(default_factory=list) + mastery_signals: list[str] = Field(default_factory=list) + status: TrustStatus = "needs_review" + notes: list[str] = Field(default_factory=list) + +class DraftPackData(BaseModel): + pack: dict = Field(default_factory=dict) + concepts: list[ConceptReviewEntry] = Field(default_factory=list) + conflicts: list[str] = Field(default_factory=list) + review_flags: list[str] = Field(default_factory=list) + attribution: dict = Field(default_factory=dict) + +class ReviewAction(BaseModel): + action_type: str + target: str = "" + payload: dict = Field(default_factory=dict) + rationale: str = "" + +class ReviewLedgerEntry(BaseModel): + reviewer: str + action: ReviewAction + +class ReviewSession(BaseModel): + reviewer: str + draft_pack: DraftPackData + ledger: list[ReviewLedgerEntry] = Field(default_factory=list) + +class WorkspaceMeta(BaseModel): + workspace_id: str + title: str + path: str + created_at: str + last_opened_at: str + notes: str = "" + +class WorkspaceRegistry(BaseModel): + workspaces: list[WorkspaceMeta] = Field(default_factory=list) + recent_workspace_ids: list[str] = Field(default_factory=list) class ImportPreview(BaseModel): ok: bool = False @@ -10,7 +57,3 @@ class ImportPreview(BaseModel): summary: dict = Field(default_factory=dict) semantic_warnings: list[str] = Field(default_factory=list) graph_warnings: list[str] = Field(default_factory=list) - path_warnings: list[str] = Field(default_factory=list) - coverage_warnings: list[str] = Field(default_factory=list) - evaluator_warnings: list[str] = Field(default_factory=list) - ledger_warnings: list[str] = Field(default_factory=list) diff --git a/src/didactopus/semantic_qa.py b/src/didactopus/semantic_qa.py index 9b4f812..c20fd05 100644 --- a/src/didactopus/semantic_qa.py +++ b/src/didactopus/semantic_qa.py @@ -1,2 +1,82 @@ -def semantic_qa_for_pack(source_dir): - return {'warnings': [], 'summary': {'semantic_warning_count': 0}} +from __future__ import annotations +import re +from difflib import SequenceMatcher +from .pack_validator import load_pack_artifacts + +BROAD_HINTS = {"and", "overview", "foundations", "introduction", "basics", "advanced"} + +def normalize_title(text: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() + +def similarity(a: str, b: str) -> float: + return SequenceMatcher(None, normalize_title(a), normalize_title(b)).ratio() + +def token_set(text: str) -> set[str]: + return {t for t in normalize_title(text).split() if t} + +def semantic_qa_for_pack(source_dir) -> dict: + loaded = load_pack_artifacts(source_dir) + if not loaded["ok"]: + return {"warnings": [], "summary": {"semantic_warning_count": 0}} + + pack = loaded["artifacts"]["pack"] + concepts = loaded["artifacts"]["concepts"].get("concepts", []) or [] + roadmap = loaded["artifacts"]["roadmap"].get("stages", []) or [] + + warnings: list[str] = [] + + for i in range(len(concepts)): + for j in range(i + 1, len(concepts)): + a = concepts[i] + b = concepts[j] + sim = similarity(a.get("title", ""), b.get("title", "")) + if sim >= 0.86 and a.get("id") != b.get("id"): + warnings.append(f"Near-duplicate concept titles: '{a.get('title')}' vs '{b.get('title')}'") + + for concept in concepts: + title = concept.get("title", "") + toks = token_set(title) + if len(toks) >= 3 and (BROAD_HINTS & toks): + warnings.append(f"Concept '{title}' may be over-broad and may need splitting.") + if " and " in title.lower(): + warnings.append(f"Concept '{title}' is compound and may combine multiple ideas.") + + for i in range(len(concepts)): + for j in range(i + 1, len(concepts)): + da = str(concepts[i].get("description", "") or "") + db = str(concepts[j].get("description", "") or "") + if len(da) > 20 and len(db) > 20: + sim = SequenceMatcher(None, da.lower(), db.lower()).ratio() + if sim >= 0.82: + warnings.append( + f"Concept descriptions are very similar: '{concepts[i].get('title')}' vs '{concepts[j].get('title')}'" + ) + + for concept in concepts: + title = normalize_title(concept.get("title", "")) + prereqs = concept.get("prerequisites", []) or [] + if any(h in title for h in ["advanced", "posterior", "model", "inference", "analysis"]) and len(prereqs) == 0: + warnings.append(f"Concept '{concept.get('title')}' looks advanced but has no prerequisites.") + + concept_by_id = {c.get("id"): c for c in concepts if c.get("id")} + for idx in range(len(roadmap) - 1): + current_stage = roadmap[idx] + next_stage = roadmap[idx + 1] + current_titles = [concept_by_id[cid].get("title", "") for cid in current_stage.get("concepts", []) if cid in concept_by_id] + next_titles = [concept_by_id[cid].get("title", "") for cid in next_stage.get("concepts", []) if cid in concept_by_id] + current_tokens = set().union(*[token_set(t) for t in current_titles]) if current_titles else set() + next_tokens = set().union(*[token_set(t) for t in next_titles]) if next_titles else set() + overlap = current_tokens & next_tokens + if current_titles and next_titles and len(overlap) == 0: + warnings.append( + f"Roadmap transition from stage '{current_stage.get('title')}' to '{next_stage.get('title')}' may lack a bridge concept." + ) + if len(next_titles) == 1 and len(current_titles) >= 2 and len(overlap) == 0: + warnings.append( + f"Stage '{next_stage.get('title')}' contains a singleton concept with weak visible continuity from the prior stage." + ) + + return { + "warnings": warnings, + "summary": {"semantic_warning_count": len(warnings), "pack_name": pack.get("name", "")}, + } diff --git a/src/didactopus/workspace_manager.py b/src/didactopus/workspace_manager.py index 5496061..f17666e 100644 --- a/src/didactopus/workspace_manager.py +++ b/src/didactopus/workspace_manager.py @@ -38,7 +38,15 @@ class WorkspaceManager: ) if not (draft_dir / "concepts.yaml").exists(): (draft_dir / "concepts.yaml").write_text("concepts: []\n", encoding="utf-8") - meta = WorkspaceMeta(workspace_id=workspace_id, title=title, path=str(workspace_dir), created_at=utc_now(), last_opened_at=utc_now(), notes=notes) + + meta = WorkspaceMeta( + workspace_id=workspace_id, + title=title, + path=str(workspace_dir), + created_at=utc_now(), + last_opened_at=utc_now(), + notes=notes, + ) registry.workspaces = [w for w in registry.workspaces if w.workspace_id != workspace_id] + [meta] registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id] self.save_registry(registry) @@ -49,7 +57,9 @@ class WorkspaceManager: target = None for ws in registry.workspaces: if ws.workspace_id == workspace_id: - ws.last_opened_at = utc_now(); target = ws; break + ws.last_opened_at = utc_now() + target = ws + break if target is not None: registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id] self.save_registry(registry) @@ -58,7 +68,8 @@ class WorkspaceManager: def get_workspace(self, workspace_id: str) -> WorkspaceMeta | None: registry = self.load_registry() for ws in registry.workspaces: - if ws.workspace_id == workspace_id: return ws + if ws.workspace_id == workspace_id: + return ws return None def preview_import(self, source_dir: str | Path, workspace_id: str): @@ -76,20 +87,27 @@ class WorkspaceManager: existing = self.get_workspace(workspace_id) if existing is not None and not allow_overwrite: raise FileExistsError(f"Workspace '{workspace_id}' already exists; set allow_overwrite to replace its draft pack.") - meta = existing if existing is not None else self.create_workspace(workspace_id, title or workspace_id, notes=notes) - if existing is not None: + + meta = existing + if meta is None: + meta = self.create_workspace(workspace_id, title or workspace_id, notes=notes) + else: self.touch_recent(workspace_id) + workspace_dir = Path(meta.path) target_draft = workspace_dir / "draft_pack" if target_draft.exists(): shutil.rmtree(target_draft) shutil.copytree(Path(source_dir), target_draft) + registry = self.load_registry() for ws in registry.workspaces: if ws.workspace_id == workspace_id: ws.last_opened_at = utc_now() - if title: ws.title = title - if notes: ws.notes = notes + if title: + ws.title = title + if notes: + ws.notes = notes meta = ws break registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id] diff --git a/tests/test_import_validator.py b/tests/test_import_validator.py index 1a9dc0a..7065540 100644 --- a/tests/test_import_validator.py +++ b/tests/test_import_validator.py @@ -1,13 +1,14 @@ from pathlib import Path from didactopus.import_validator import preview_draft_pack_import -def test_preview_includes_ledger_warnings(tmp_path: Path) -> None: +def test_preview_includes_graph_warnings(tmp_path: Path) -> None: (tmp_path / "pack.yaml").write_text("name: p\ndisplay_name: P\nversion: 0.1.0\n", encoding="utf-8") - (tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: Foundations\n description: enough description here\n mastery_signals: [Explain foundations]\n", encoding="utf-8") - (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [c1]\n checkpoint: [oral discussion]\n", encoding="utf-8") - (tmp_path / "projects.yaml").write_text("projects:\n - id: p1\n title: Project\n prerequisites: [c1]\n deliverables: [memo]\n", encoding="utf-8") - (tmp_path / "rubrics.yaml").write_text("rubrics:\n - id: r1\n title: Style\n criteria: [formatting]\n", encoding="utf-8") - (tmp_path / "evaluator.yaml").write_text("dimensions:\n - name: typography\n description: page polish\n", encoding="utf-8") - (tmp_path / "mastery_ledger.yaml").write_text("entry_schema:\n concept_id: str\n score: float\n", encoding="utf-8") + (tmp_path / "concepts.yaml").write_text( + "concepts:\n - id: a\n title: A\n description: description long enough\n prerequisites: [b]\n - id: b\n title: B\n description: description long enough\n prerequisites: [a]\n", + encoding="utf-8" + ) + (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [a,b]\n", encoding="utf-8") + (tmp_path / "projects.yaml").write_text("projects: []\n", encoding="utf-8") + (tmp_path / "rubrics.yaml").write_text("rubrics: []\n", encoding="utf-8") preview = preview_draft_pack_import(tmp_path, "ws1") - assert isinstance(preview.ledger_warnings, list) + assert isinstance(preview.graph_warnings, list) diff --git a/tests/test_pack_validator.py b/tests/test_pack_validator.py index 3c13676..85a120c 100644 --- a/tests/test_pack_validator.py +++ b/tests/test_pack_validator.py @@ -5,17 +5,7 @@ def test_valid_pack(tmp_path: Path) -> None: (tmp_path / "pack.yaml").write_text("name: p\ndisplay_name: P\nversion: 0.1.0\n", encoding="utf-8") (tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: C1\n description: A full enough description.\n", encoding="utf-8") (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: S1\n concepts: [c1]\n", encoding="utf-8") - (tmp_path / "projects.yaml").write_text("projects:\n - id: p1\n title: P1\n prerequisites: [c1]\n", encoding="utf-8") - (tmp_path / "rubrics.yaml").write_text("rubrics:\n - id: r1\n title: R1\n criteria: [correctness]\n", encoding="utf-8") + (tmp_path / "projects.yaml").write_text("projects: []\n", encoding="utf-8") + (tmp_path / "rubrics.yaml").write_text("rubrics: []\n", encoding="utf-8") result = validate_pack_directory(tmp_path) assert result["ok"] is True - -def test_cross_file_errors(tmp_path: Path) -> None: - (tmp_path / "pack.yaml").write_text("name: p\ndisplay_name: P\nversion: 0.1.0\n", encoding="utf-8") - (tmp_path / "concepts.yaml").write_text("concepts:\n - id: dup\n title: A\n description: Thin.\n - id: dup\n title: B\n description: Thin.\n", encoding="utf-8") - (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: S1\n concepts: [missing]\n", encoding="utf-8") - (tmp_path / "projects.yaml").write_text("projects:\n - id: p1\n title: P1\n prerequisites: [missing]\n", encoding="utf-8") - (tmp_path / "rubrics.yaml").write_text("rubrics:\n - id: r1\n title: R1\n criteria: broken\n", encoding="utf-8") - result = validate_pack_directory(tmp_path) - assert result["ok"] is False - assert any("Duplicate concept id" in e for e in result["errors"]) diff --git a/tests/test_webui_files.py b/tests/test_webui_files.py index a7a6bf2..c0e0c6e 100644 --- a/tests/test_webui_files.py +++ b/tests/test_webui_files.py @@ -1,4 +1,5 @@ from pathlib import Path + def test_webui_scaffold_exists() -> None: assert Path("webui/src/App.jsx").exists() assert Path("webui/package.json").exists() diff --git a/webui/index.html b/webui/index.html index df9504e..4ecb1aa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6,5 +6,7 @@