diff --git a/bad-generated-pack/pack.yaml b/bad-generated-pack/pack.yaml index 590cbc9..ecadbb9 100644 --- a/bad-generated-pack/pack.yaml +++ b/bad-generated-pack/pack.yaml @@ -1,3 +1 @@ -name: broken-pack -display_name: Broken Pack -version: 0.1.0-draft +display_name Imported Pack Broken YAML diff --git a/docs/faq.md b/docs/faq.md index d7c10bd..ff43624 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,24 +1,28 @@ # FAQ -## Why add a draft-pack import feature? +## Why add import validation? -Because the transition from generated draft pack to curated workspace is one of the -places where users can lose momentum. +Because reducing startup friction does not mean hiding risk. A user still needs +a clear signal about whether a generated draft pack is structurally usable. -## How does this relate to the activation-energy goal? +## How does this support the activation-energy goal? -Even when online course contents can be ingested, people may still stall if the next -steps are awkward. Importing a draft pack into a workspace should feel like one -smooth continuation, not a separate manual task. +It removes uncertainty from the handoff step. Users can see whether a draft pack +looks valid before committing it into a workspace. -## What does import do? +## What does the preview check do? -In this scaffold it: -- creates or updates a workspace -- copies a source draft-pack directory into that workspace -- makes it ready to open in the review UI +In this scaffold it checks: +- required files +- basic YAML parsing +- key metadata presence +- concept count +- overwrite conditions -## Is the import workflow validated? +## Does preview guarantee correctness? -Only lightly in this scaffold. A future revision should add stronger schema checks, -collision handling, and overwrite safeguards. +No. It is a safety and structure check, not a guarantee of pedagogical quality. + +## Can import still overwrite an existing workspace? + +Yes, but only if overwrite is explicitly allowed. diff --git a/pyproject.toml b/pyproject.toml index 38fb750..72c17de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "didactopus" version = "0.1.0" -description = "Didactopus: draft-pack import workflow for workspaces" +description = "Didactopus: import validation and safety layer" readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} diff --git a/src/didactopus/config.py b/src/didactopus/config.py index c9dfa87..77b988d 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -2,24 +2,20 @@ from pathlib import Path from pydantic import BaseModel, Field import yaml - class ReviewConfig(BaseModel): default_reviewer: str = "Unknown Reviewer" write_promoted_pack: bool = True - class BridgeConfig(BaseModel): host: str = "127.0.0.1" port: int = 8765 registry_path: str = "workspace_registry.json" default_workspace_root: str = "workspaces" - class AppConfig(BaseModel): review: ReviewConfig = Field(default_factory=ReviewConfig) bridge: BridgeConfig = Field(default_factory=BridgeConfig) - def load_config(path: str | Path) -> AppConfig: with open(path, "r", encoding="utf-8") as handle: data = yaml.safe_load(handle) or {} diff --git a/src/didactopus/import_validator.py b/src/didactopus/import_validator.py index 8bde4f5..c0f6840 100644 --- a/src/didactopus/import_validator.py +++ b/src/didactopus/import_validator.py @@ -1,20 +1,56 @@ from __future__ import annotations from pathlib import Path +import yaml from .review_schema import ImportPreview -from .pack_validator import validate_pack_directory -from .semantic_qa import semantic_qa_for_pack + +REQUIRED_FILES = ["pack.yaml", "concepts.yaml"] 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": [], "summary": {}} - preview = ImportPreview( - source_dir=str(Path(source_dir)), - workspace_id=workspace_id, - overwrite_required=overwrite_required, - ok=result["ok"], - errors=list(result["errors"]), - warnings=list(result["warnings"]), - summary=dict(result["summary"]), - semantic_warnings=list(semantic["warnings"]), - ) + source = Path(source_dir) + preview = ImportPreview(source_dir=str(source), workspace_id=workspace_id, overwrite_required=overwrite_required) + + if not source.exists(): + preview.errors.append(f"Source directory does not exist: {source}") + return preview + if not source.is_dir(): + preview.errors.append(f"Source path is not a directory: {source}") + return preview + + for filename in REQUIRED_FILES: + if not (source / filename).exists(): + preview.errors.append(f"Missing required file: {filename}") + + pack_data = {} + concepts_data = {} + if not preview.errors: + try: + pack_data = yaml.safe_load((source / "pack.yaml").read_text(encoding="utf-8")) or {} + except Exception as exc: + preview.errors.append(f"Could not parse pack.yaml: {exc}") + try: + concepts_data = yaml.safe_load((source / "concepts.yaml").read_text(encoding="utf-8")) or {} + except Exception as exc: + preview.errors.append(f"Could not parse concepts.yaml: {exc}") + + if not preview.errors: + if "name" not in pack_data: + preview.warnings.append("pack.yaml has no 'name' field.") + if "display_name" not in pack_data: + preview.warnings.append("pack.yaml has no 'display_name' field.") + concepts = concepts_data.get("concepts", []) + if not isinstance(concepts, list): + preview.errors.append("concepts.yaml top-level 'concepts' is not a list.") + else: + preview.summary = { + "pack_name": pack_data.get("name", ""), + "display_name": pack_data.get("display_name", ""), + "version": pack_data.get("version", ""), + "concept_count": len(concepts), + "has_conflict_report": (source / "conflict_report.md").exists(), + "has_review_report": (source / "review_report.md").exists(), + } + if len(concepts) == 0: + preview.warnings.append("concepts.yaml contains zero concepts.") + + preview.ok = len(preview.errors) == 0 return preview diff --git a/src/didactopus/review_bridge_server.py b/src/didactopus/review_bridge_server.py index c136cac..a07c20c 100644 --- a/src/didactopus/review_bridge_server.py +++ b/src/didactopus/review_bridge_server.py @@ -73,16 +73,31 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler): 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( + 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( source_dir=payload["source_dir"], workspace_id=payload["workspace_id"], title=payload.get("title"), - notes=payload.get("notes", "") + notes=payload.get("notes", ""), + allow_overwrite=bool(payload.get("allow_overwrite", False)), ) except FileNotFoundError as exc: - json_response(self, 404, {"error": str(exc)}) + json_response(self, 404, {"ok": False, "error": str(exc)}) + return + except FileExistsError as exc: + json_response(self, 409, {"ok": False, "error": str(exc)}) + return + except ValueError as exc: + json_response(self, 400, {"ok": False, "error": str(exc)}) return self.set_active_workspace(meta.workspace_id) json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) @@ -105,7 +120,7 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler): json_response(self, 404, {"error": "not found"}) def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Didactopus local review bridge server with workspace manager") + parser = argparse.ArgumentParser(description="Didactopus local review bridge server with import validation") parser.add_argument("--config", default="configs/config.example.yaml") return parser diff --git a/src/didactopus/review_schema.py b/src/didactopus/review_schema.py index 5bc0db0..0f542d2 100644 --- a/src/didactopus/review_schema.py +++ b/src/didactopus/review_schema.py @@ -46,3 +46,12 @@ class WorkspaceMeta(BaseModel): 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 + source_dir: str + workspace_id: str + overwrite_required: bool = False + errors: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + summary: dict = Field(default_factory=dict) diff --git a/src/didactopus/workspace_manager.py b/src/didactopus/workspace_manager.py index c32c72e..f17666e 100644 --- a/src/didactopus/workspace_manager.py +++ b/src/didactopus/workspace_manager.py @@ -3,6 +3,7 @@ from pathlib import Path from datetime import datetime, UTC import json, shutil from .review_schema import WorkspaceMeta, WorkspaceRegistry +from .import_validator import preview_draft_pack_import def utc_now() -> str: return datetime.now(UTC).isoformat() @@ -71,12 +72,23 @@ class WorkspaceManager: return ws return None - def import_draft_pack(self, source_dir: str | Path, workspace_id: str, title: str | None = None, notes: str = "") -> WorkspaceMeta: - source_dir = Path(source_dir) - if not source_dir.exists(): - raise FileNotFoundError(f"Draft pack source does not exist: {source_dir}") + def preview_import(self, source_dir: str | Path, workspace_id: str): + preview = preview_draft_pack_import(source_dir, workspace_id) + existing = self.get_workspace(workspace_id) + if existing is not None: + preview.overwrite_required = True + preview.warnings.append(f"Workspace '{workspace_id}' already exists and import will overwrite draft_pack.") + return preview - meta = self.get_workspace(workspace_id) + def import_draft_pack(self, source_dir: str | Path, workspace_id: str, title: str | None = None, notes: str = "", allow_overwrite: bool = False) -> WorkspaceMeta: + preview = self.preview_import(source_dir, workspace_id) + if not preview.ok: + raise ValueError("Draft pack preview failed: " + "; ".join(preview.errors)) + 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 meta is None: meta = self.create_workspace(workspace_id, title or workspace_id, notes=notes) else: @@ -86,7 +98,7 @@ class WorkspaceManager: target_draft = workspace_dir / "draft_pack" if target_draft.exists(): shutil.rmtree(target_draft) - shutil.copytree(source_dir, target_draft) + shutil.copytree(Path(source_dir), target_draft) registry = self.load_registry() for ws in registry.workspaces: diff --git a/tests/test_import_validator.py b/tests/test_import_validator.py index 758607f..1c4cd49 100644 --- a/tests/test_import_validator.py +++ b/tests/test_import_validator.py @@ -1,14 +1,19 @@ from pathlib import Path from didactopus.import_validator import preview_draft_pack_import -def test_preview_includes_semantic_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: Prior and Posterior\n description: Beliefs before and after evidence.\n - id: c2\n title: Posterior Analysis\n description: Beliefs before and after evidence.\n", - encoding="utf-8" - ) - (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: Foundations\n concepts: [c1]\n - id: s2\n title: Advanced Inference\n concepts: [c2]\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.semantic_warnings, list) +def test_valid_preview(tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "pack.yaml").write_text("name: p\ndisplay_name: P\nversion: 0.1.0\n", encoding="utf-8") + (src / "concepts.yaml").write_text("concepts: []\n", encoding="utf-8") + preview = preview_draft_pack_import(src, "ws1") + assert preview.ok is True + assert preview.summary["concept_count"] == 0 + +def test_missing_required_file(tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "pack.yaml").write_text("name: p\n", encoding="utf-8") + preview = preview_draft_pack_import(src, "ws1") + assert preview.ok is False + assert any("Missing required file" in e for e in preview.errors) diff --git a/tests/test_workspace_manager.py b/tests/test_workspace_manager.py index 91f2ca4..c9e4630 100644 --- a/tests/test_workspace_manager.py +++ b/tests/test_workspace_manager.py @@ -1,20 +1,25 @@ from pathlib import Path from didactopus.workspace_manager import WorkspaceManager -def test_create_and_get_workspace(tmp_path: Path) -> None: - mgr = WorkspaceManager(tmp_path / "registry.json", tmp_path / "roots") - meta = mgr.create_workspace("ws1", "Workspace One") - assert meta.workspace_id == "ws1" - got = mgr.get_workspace("ws1") - assert got is not None - assert got.title == "Workspace One" - -def test_import_draft_pack(tmp_path: Path) -> None: +def test_import_preview_and_overwrite_warning(tmp_path: Path) -> None: src = tmp_path / "srcpack" src.mkdir() - (src / "pack.yaml").write_text("name: x\n", encoding="utf-8") + (src / "pack.yaml").write_text("name: x\ndisplay_name: X\nversion: 0.1.0\n", encoding="utf-8") (src / "concepts.yaml").write_text("concepts: []\n", encoding="utf-8") mgr = WorkspaceManager(tmp_path / "registry.json", tmp_path / "roots") - meta = mgr.import_draft_pack(src, "ws2", title="Workspace Two") - assert meta.workspace_id == "ws2" - assert (tmp_path / "roots" / "ws2" / "draft_pack" / "pack.yaml").exists() + mgr.create_workspace("ws2", "Workspace Two") + preview = mgr.preview_import(src, "ws2") + assert preview.overwrite_required is True + +def test_import_draft_pack_requires_overwrite_flag(tmp_path: Path) -> None: + src = tmp_path / "srcpack" + src.mkdir() + (src / "pack.yaml").write_text("name: x\ndisplay_name: X\nversion: 0.1.0\n", encoding="utf-8") + (src / "concepts.yaml").write_text("concepts: []\n", encoding="utf-8") + mgr = WorkspaceManager(tmp_path / "registry.json", tmp_path / "roots") + mgr.create_workspace("ws2", "Workspace Two") + try: + mgr.import_draft_pack(src, "ws2", title="Workspace Two", allow_overwrite=False) + assert False, "Expected FileExistsError" + except FileExistsError: + assert True diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 87c3027..508ba6a 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -8,6 +8,8 @@ export default function App() { const [workspaceId, setWorkspaceId] = useState(""); const [workspaceTitle, setWorkspaceTitle] = useState(""); const [importSource, setImportSource] = useState(""); + const [importPreview, setImportPreview] = useState(null); + const [allowOverwrite, setAllowOverwrite] = useState(false); const [session, setSession] = useState(null); const [selectedId, setSelectedId] = useState(""); const [pendingActions, setPendingActions] = useState([]); @@ -17,7 +19,7 @@ export default function App() { const res = await fetch(`${API}/api/workspaces`); const data = await res.json(); setRegistry(data); - if (!session) setMessage("Choose, create, or import a workspace."); + if (!session) setMessage("Choose, create, preview, or import a workspace."); } useEffect(() => { @@ -35,6 +37,22 @@ export default function App() { await openWorkspace(workspaceId); } + async function previewImport() { + if (!workspaceId || !importSource) return; + const res = await fetch(`${API}/api/workspaces/import-preview`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ workspace_id: workspaceId, source_dir: importSource }) + }); + const data = await res.json(); + setImportPreview(data); + if (data.ok) { + setMessage("Import preview ready."); + } else { + setMessage("Import preview found blocking errors."); + } + } + async function importWorkspace() { if (!workspaceId || !importSource) return; const res = await fetch(`${API}/api/workspaces/import`, { @@ -43,7 +61,8 @@ export default function App() { body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle || workspaceId, - source_dir: importSource + source_dir: importSource, + allow_overwrite: allowOverwrite }) }); const data = await res.json(); @@ -138,10 +157,10 @@ export default function App() {
-

Didactopus Workspace Manager

+

Didactopus Import Validation

- Reduce the activation-energy hump from raw course material to reviewed domain pack - by organizing draft-pack curation as a manageable local workflow. + Reduce the activation-energy hump from generated draft packs to curated review workspaces + by previewing structure, warnings, and overwrite risk before import.

{message}
@@ -159,10 +178,14 @@ export default function App() {
-

Import Draft Pack

+

Preview / Import Draft Pack

- + +
+ + +

Recent

@@ -174,6 +197,27 @@ export default function App() {
+ {importPreview && ( +
+
+

Import Preview

+
OK: {String(importPreview.ok)}
+
Overwrite Required: {String(importPreview.overwrite_required)}
+
Pack: {importPreview.summary?.display_name || importPreview.summary?.pack_name || "-"}
+
Version: {importPreview.summary?.version || "-"}
+
Concept Count: {importPreview.summary?.concept_count ?? "-"}
+
+
+

Errors

+ +
+
+

Warnings

+ +
+
+ )} + {session && (