Apply ZIP update: 095-didactopus-import-validation-safety-update.zip [2026-03-14T13:18:49]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 6a983676b4
commit 8c9776d6fe
12 changed files with 206 additions and 78 deletions

View File

@ -1,3 +1 @@
name: broken-pack
display_name: Broken Pack
version: 0.1.0-draft
display_name Imported Pack Broken YAML

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
<div className="page">
<header className="hero">
<div>
<h1>Didactopus Workspace Manager</h1>
<h1>Didactopus Import Validation</h1>
<p>
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.
</p>
<div className="small">{message}</div>
</div>
@ -159,10 +178,14 @@ export default function App() {
<button onClick={createWorkspace}>Create</button>
</div>
<div className="card">
<h2>Import Draft Pack</h2>
<h2>Preview / Import Draft Pack</h2>
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
<label>Draft Pack Source Directory<input value={importSource} onChange={(e) => setImportSource(e.target.value)} placeholder="e.g. generated-pack" /></label>
<button onClick={importWorkspace}>Import into Workspace</button>
<label className="checkline"><input type="checkbox" checked={allowOverwrite} onChange={(e) => setAllowOverwrite(e.target.checked)} /> Allow overwrite of existing workspace draft_pack</label>
<div className="button-row">
<button onClick={previewImport}>Preview</button>
<button onClick={importWorkspace}>Import</button>
</div>
</div>
<div className="card">
<h2>Recent</h2>
@ -174,6 +197,27 @@ export default function App() {
</div>
</section>
{importPreview && (
<section className="preview-grid">
<div className="card">
<h2>Import Preview</h2>
<div><strong>OK:</strong> {String(importPreview.ok)}</div>
<div><strong>Overwrite Required:</strong> {String(importPreview.overwrite_required)}</div>
<div><strong>Pack:</strong> {importPreview.summary?.display_name || importPreview.summary?.pack_name || "-"}</div>
<div><strong>Version:</strong> {importPreview.summary?.version || "-"}</div>
<div><strong>Concept Count:</strong> {importPreview.summary?.concept_count ?? "-"}</div>
</div>
<div className="card">
<h2>Errors</h2>
<ul>{(importPreview.errors || []).length ? importPreview.errors.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
<div className="card">
<h2>Warnings</h2>
<ul>{(importPreview.warnings || []).length ? importPreview.warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
</section>
)}
{session && (
<main className="layout">
<aside className="sidebar">

View File

@ -15,6 +15,7 @@ body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--b
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
button:hover { border-color: var(--accent); }
.summary-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.preview-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.layout { margin-top: 16px; display: grid; grid-template-columns: 290px 1fr 360px; gap: 16px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 16px; }
.sidebar, .content, .rightbar { display: flex; flex-direction: column; gap: 16px; }
@ -30,7 +31,10 @@ input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--
.small { color: var(--muted); }
.conflict { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px; }
ul { padding-left: 18px; }
.button-row { display: flex; gap: 10px; }
.checkline { display: flex; gap: 10px; align-items: center; }
.checkline input { width: auto; margin-top: 0; }
@media (max-width: 1100px) {
.summary-grid { grid-template-columns: repeat(2, 1fr); }
.summary-grid, .preview-grid { grid-template-columns: repeat(2, 1fr); }
.layout { grid-template-columns: 1fr; }
}