Apply ZIP update: 125-didactopus-graph-prereq-analysis-update.zip [2026-03-14T13:19:09]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 59005ddb01
commit 943f65e0e1
27 changed files with 906 additions and 185 deletions

View File

@ -1,11 +1,33 @@
concepts: concepts:
- id: c1 - id: a
title: Foundations title: Foundations
description: Broad foundations topic. description: Introductory foundations concept with broad scope.
mastery_signals: prerequisites: [c]
- Explain core foundations clearly. - id: b
- id: c2 title: Advanced Analysis
description: Advanced analysis of results in a difficult domain.
prerequisites: [a]
- id: c
title: Methods title: Methods
description: Methods topic. description: Methods and procedures for analysis in context.
mastery_signals: prerequisites: [b]
- Use methods appropriately. - 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]

View File

@ -1,6 +1 @@
projects: projects: []
- id: p1
title: Final Memo
prerequisites: [c1]
deliverables:
- brief memo

View File

@ -1,6 +1,13 @@
stages: stages:
- id: stage-1 - id: stage-1
title: Start title: Foundations
concepts: [c1, c2] concepts: [a, bottleneck]
checkpoint: - id: stage-2
- oral discussion title: Advanced Analysis
concepts: [b, d1]
- id: stage-3
title: Methods
concepts: [c, d2, d3]
- id: stage-4
title: Detached Topic
concepts: [isolated]

View File

@ -1,4 +1 @@
rubrics: rubrics: []
- id: r1
title: Basic
criteria: [style, formatting]

View File

@ -1,9 +1,24 @@
# FAQ # 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.

View File

@ -2,10 +2,14 @@ concepts:
- id: bayes-prior - id: bayes-prior
title: Bayes Prior title: Bayes Prior
description: Prior beliefs before evidence in a probabilistic model. description: Prior beliefs before evidence in a probabilistic model.
mastery_signals: prerequisites: []
- Explain a prior distribution clearly.
- id: bayes-posterior - id: bayes-posterior
title: Bayes Posterior title: Bayes Posterior
description: Updated beliefs after evidence in a probabilistic model. description: Updated beliefs after evidence in a probabilistic model.
mastery_signals: prerequisites:
- Compare prior and posterior beliefs. - bayes-prior
- id: model-checking
title: Model Checking
description: Evaluate whether model assumptions and fit remain plausible.
prerequisites:
- bayes-posterior

View File

@ -1,7 +1 @@
projects: projects: []
- id: p1
title: Final Bayesian Comparison
prerequisites: [bayes-prior, bayes-posterior]
deliverables:
- explanation
- comparison report

View File

@ -2,10 +2,9 @@ stages:
- id: stage-1 - id: stage-1
title: Prior Beliefs title: Prior Beliefs
concepts: [bayes-prior] concepts: [bayes-prior]
checkpoint:
- explanation exercise on prior distribution
- id: stage-2 - id: stage-2
title: Posterior Updating title: Posterior Updating
concepts: [bayes-posterior] concepts: [bayes-posterior]
checkpoint: - id: stage-3
- comparison exercise on prior and posterior beliefs title: Model Checking
concepts: [model-checking]

View File

@ -1,4 +1 @@
rubrics: rubrics: []
- id: r1
title: Basic
criteria: [correctness, explanation]

View File

@ -5,8 +5,18 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
description = "Didactopus: graph-aware prerequisite analysis"
readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {text = "MIT"}
authors = [{name = "Wesley R. Elsberry"}]
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] 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] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1 +1 @@
__version__ = '0.1.0' __version__ = "0.1.0"

View File

@ -1,2 +1,91 @@
def graph_qa_for_pack(source_dir): from __future__ import annotations
return {'warnings': [], 'summary': {'graph_warning_count': 0}} 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}

View File

@ -1,22 +1,15 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
from .review_schema import ImportPreview from .review_schema import ImportPreview
from .pack_validator import validate_pack_directory from .pack_validator import validate_pack_directory
from .semantic_qa import semantic_qa_for_pack from .semantic_qa import semantic_qa_for_pack
from .graph_qa import graph_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) result = validate_pack_directory(source_dir)
semantic = semantic_qa_for_pack(source_dir) if result["ok"] else {"warnings": []} 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": []} graph = graph_qa_for_pack(source_dir) if result["ok"] else {"warnings": [], "summary": {}}
pathq = path_quality_for_pack(source_dir) if result["ok"] else {"warnings": []} preview = ImportPreview(
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(
source_dir=str(Path(source_dir)), source_dir=str(Path(source_dir)),
workspace_id=workspace_id, workspace_id=workspace_id,
overwrite_required=overwrite_required, overwrite_required=overwrite_required,
@ -26,8 +19,5 @@ def preview_draft_pack_import(source_dir, workspace_id, overwrite_required=False
summary=dict(result["summary"]), summary=dict(result["summary"]),
semantic_warnings=list(semantic["warnings"]), semantic_warnings=list(semantic["warnings"]),
graph_warnings=list(graph["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

View File

@ -1,45 +1,141 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
import yaml 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: try:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {} return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc: except Exception as exc:
errors.append(f"Could not parse {label}: {exc}") errors.append(f"Could not parse {label}: {exc}")
return {} return {}
def load_pack_artifacts(source_dir): def load_pack_artifacts(source_dir: str | Path) -> dict:
source = Path(source_dir) source = Path(source_dir)
errors = [] errors: list[str] = []
if not source.exists(): 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(): if not source.is_dir():
return {"ok": False, "errors": [f"Source path is not a directory: {source}"], "artifacts": {}} return {"ok": False, "errors": [f"Source path is not a directory: {source}"], "warnings": [], "summary": {}, "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}
def validate_pack_directory(source_dir): for filename in REQUIRED_FILES:
loaded = load_pack_artifacts(source_dir) if not (source / filename).exists():
if not loaded["ok"]: errors.append(f"Missing required file: {filename}")
return {"ok": False, "errors": loaded["errors"], "warnings": [], "summary": {}} if errors:
arts = loaded["artifacts"] return {"ok": False, "errors": errors, "warnings": [], "summary": {}, "artifacts": {}}
concepts = arts["concepts"].get("concepts", []) or []
dims = arts["evaluator"].get("dimensions", []) or [] pack_data = _safe_load_yaml(source / "pack.yaml", errors, "pack.yaml")
summary = { concepts_data = _safe_load_yaml(source / "concepts.yaml", errors, "concepts.yaml")
"pack_name": arts["pack"].get("name", ""), roadmap_data = _safe_load_yaml(source / "roadmap.yaml", errors, "roadmap.yaml")
"display_name": arts["pack"].get("display_name", ""), projects_data = _safe_load_yaml(source / "projects.yaml", errors, "projects.yaml")
"version": arts["pack"].get("version", ""), rubrics_data = _safe_load_yaml(source / "rubrics.yaml", errors, "rubrics.yaml")
"concept_count": len(concepts), return {
"evaluator_dimension_count": len(dims), "ok": len(errors) == 0,
"ledger_field_count": len((arts["mastery_ledger"].get("entry_schema", {}) or {}).keys()), "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}

View File

@ -8,26 +8,43 @@ from .review_export import export_review_state_json, export_promoted_pack
class ReviewWorkspaceBridge: class ReviewWorkspaceBridge:
def __init__(self, workspace_dir: str | Path, reviewer: str = "Unknown Reviewer") -> None: 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 @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 @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 @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: def load_session(self) -> ReviewSession:
if self.review_session_path.exists(): 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) draft = load_draft_pack(self.draft_pack_dir)
session = ReviewSession(reviewer=self.reviewer, draft_pack=draft) session = ReviewSession(reviewer=self.reviewer, draft_pack=draft)
export_review_state_json(session, self.review_session_path) export_review_state_json(session, self.review_session_path)
return session return session
def save_session(self, session: ReviewSession) -> None: def save_session(self, session: ReviewSession) -> None:
export_review_state_json(session, self.review_session_path) export_review_state_json(session, self.review_session_path)
def apply_actions(self, actions: list[dict]) -> ReviewSession: def apply_actions(self, actions: list[dict]) -> ReviewSession:
session = self.load_session() session = self.load_session()
for action_dict in actions: for action_dict in actions:
apply_action(session, session.reviewer, ReviewAction.model_validate(action_dict)) action = ReviewAction.model_validate(action_dict)
self.save_session(session); return session apply_action(session, session.reviewer, action)
self.save_session(session)
return session
def export_promoted(self) -> ReviewSession: 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

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse, json import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import json
from pathlib import Path from pathlib import Path
from .config import load_config from .config import load_config
from .review_bridge import ReviewWorkspaceBridge from .review_bridge import ReviewWorkspaceBridge
@ -26,55 +27,100 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler):
@classmethod @classmethod
def set_active_workspace(cls, workspace_id: str) -> bool: def set_active_workspace(cls, workspace_id: str) -> bool:
meta = cls.workspace_manager.touch_recent(workspace_id) 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_workspace_id = workspace_id
cls.active_bridge = ReviewWorkspaceBridge(meta.path, reviewer=cls.reviewer) cls.active_bridge = ReviewWorkspaceBridge(meta.path, reviewer=cls.reviewer)
return True 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): def do_GET(self):
if self.path == "/api/workspaces": 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.path == "/api/load":
if self.active_bridge is None: return json_response(self, 400, {"error": "no active workspace"}) if self.active_bridge is None:
return json_response(self, 200, {"workspace_id": self.active_workspace_id, "session": self.active_bridge.load_session().model_dump()}) json_response(self, 400, {"error": "no active workspace"})
return json_response(self, 404, {"error": "not found"}) 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): def do_POST(self):
length = int(self.headers.get("Content-Length", "0")) 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": if self.path == "/api/workspaces/create":
meta = self.workspace_manager.create_workspace(payload["workspace_id"], payload["title"], notes=payload.get("notes", "")) meta = self.workspace_manager.create_workspace(
self.set_active_workspace(meta.workspace_id); return json_response(self, 200, {"ok": True, "workspace": meta.model_dump()}) 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": if self.path == "/api/workspaces/open":
ok = self.set_active_workspace(payload["workspace_id"]) 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": if self.path == "/api/workspaces/import-preview":
preview = self.workspace_manager.preview_import(payload["source_dir"], payload["workspace_id"]) preview = self.workspace_manager.preview_import(
return json_response(self, 200, preview.model_dump()) source_dir=payload["source_dir"],
workspace_id=payload["workspace_id"]
)
json_response(self, 200, preview.model_dump())
return
if self.path == "/api/workspaces/import": if self.path == "/api/workspaces/import":
try: 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: 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: 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: 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) 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: 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": 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": if self.path == "/api/export":
session = self.active_bridge.export_promoted() 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()}) 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"}) return
json_response(self, 404, {"error": "not found"})
def build_parser() -> argparse.ArgumentParser: 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") parser.add_argument("--config", default="configs/config.example.yaml")
return parser return parser
@ -82,7 +128,13 @@ def main() -> None:
args = build_parser().parse_args() args = build_parser().parse_args()
config = load_config(Path(args.config)) config = load_config(Path(args.config))
ReviewBridgeHandler.reviewer = config.review.default_reviewer 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) server = HTTPServer((config.bridge.host, config.bridge.port), ReviewBridgeHandler)
print(f"Didactopus review bridge listening on http://{config.bridge.host}:{config.bridge.port}") print(f"Didactopus review bridge listening on http://{config.bridge.host}:{config.bridge.port}")
server.serve_forever() server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -1,4 +1,51 @@
from __future__ import annotations
from pydantic import BaseModel, Field 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): class ImportPreview(BaseModel):
ok: bool = False ok: bool = False
@ -10,7 +57,3 @@ class ImportPreview(BaseModel):
summary: dict = Field(default_factory=dict) summary: dict = Field(default_factory=dict)
semantic_warnings: list[str] = Field(default_factory=list) semantic_warnings: list[str] = Field(default_factory=list)
graph_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)

View File

@ -1,2 +1,82 @@
def semantic_qa_for_pack(source_dir): from __future__ import annotations
return {'warnings': [], 'summary': {'semantic_warning_count': 0}} 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", "")},
}

View File

@ -38,7 +38,15 @@ class WorkspaceManager:
) )
if not (draft_dir / "concepts.yaml").exists(): if not (draft_dir / "concepts.yaml").exists():
(draft_dir / "concepts.yaml").write_text("concepts: []\n", encoding="utf-8") (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.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] registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id]
self.save_registry(registry) self.save_registry(registry)
@ -49,7 +57,9 @@ class WorkspaceManager:
target = None target = None
for ws in registry.workspaces: for ws in registry.workspaces:
if ws.workspace_id == workspace_id: 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: if target is not None:
registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id] registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id]
self.save_registry(registry) self.save_registry(registry)
@ -58,7 +68,8 @@ class WorkspaceManager:
def get_workspace(self, workspace_id: str) -> WorkspaceMeta | None: def get_workspace(self, workspace_id: str) -> WorkspaceMeta | None:
registry = self.load_registry() registry = self.load_registry()
for ws in registry.workspaces: for ws in registry.workspaces:
if ws.workspace_id == workspace_id: return ws if ws.workspace_id == workspace_id:
return ws
return None return None
def preview_import(self, source_dir: str | Path, workspace_id: str): def preview_import(self, source_dir: str | Path, workspace_id: str):
@ -76,20 +87,27 @@ class WorkspaceManager:
existing = self.get_workspace(workspace_id) existing = self.get_workspace(workspace_id)
if existing is not None and not allow_overwrite: 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.") 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) self.touch_recent(workspace_id)
workspace_dir = Path(meta.path) workspace_dir = Path(meta.path)
target_draft = workspace_dir / "draft_pack" target_draft = workspace_dir / "draft_pack"
if target_draft.exists(): if target_draft.exists():
shutil.rmtree(target_draft) shutil.rmtree(target_draft)
shutil.copytree(Path(source_dir), target_draft) shutil.copytree(Path(source_dir), target_draft)
registry = self.load_registry() registry = self.load_registry()
for ws in registry.workspaces: for ws in registry.workspaces:
if ws.workspace_id == workspace_id: if ws.workspace_id == workspace_id:
ws.last_opened_at = utc_now() ws.last_opened_at = utc_now()
if title: ws.title = title if title:
if notes: ws.notes = notes ws.title = title
if notes:
ws.notes = notes
meta = ws meta = ws
break break
registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id] registry.recent_workspace_ids = [workspace_id] + [w for w in registry.recent_workspace_ids if w != workspace_id]

View File

@ -1,13 +1,14 @@
from pathlib import Path from pathlib import Path
from didactopus.import_validator import preview_draft_pack_import 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 / "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 / "concepts.yaml").write_text(
(tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [c1]\n checkpoint: [oral discussion]\n", encoding="utf-8") "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",
(tmp_path / "projects.yaml").write_text("projects:\n - id: p1\n title: Project\n prerequisites: [c1]\n deliverables: [memo]\n", encoding="utf-8") 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 / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [a,b]\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 / "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") preview = preview_draft_pack_import(tmp_path, "ws1")
assert isinstance(preview.ledger_warnings, list) assert isinstance(preview.graph_warnings, list)

View File

@ -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 / "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 / "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 / "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 / "projects.yaml").write_text("projects: []\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 / "rubrics.yaml").write_text("rubrics: []\n", encoding="utf-8")
result = validate_pack_directory(tmp_path) result = validate_pack_directory(tmp_path)
assert result["ok"] is True 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"])

View File

@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
def test_webui_scaffold_exists() -> None: def test_webui_scaffold_exists() -> None:
assert Path("webui/src/App.jsx").exists() assert Path("webui/src/App.jsx").exists()
assert Path("webui/package.json").exists() assert Path("webui/package.json").exists()

View File

@ -6,5 +6,7 @@
<title>Didactopus Review UI</title> <title>Didactopus Review UI</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body><div id="root"></div></body> <body>
<div id="root"></div>
</body>
</html> </html>

View File

@ -1 +1,17 @@
{"name":"didactopus-review-ui","private":true,"version":"0.1.0","type":"module"} {
"name": "didactopus-review-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
}

View File

@ -1,2 +1,269 @@
import React from "react"; import React, { useEffect, useMemo, useState } from "react";
export default function App(){return <div><h1>Didactopus Evidence Flow & Mastery Ledger QA</h1><p>Scaffold UI for ledger warnings.</p></div>}
const API = "http://127.0.0.1:8765";
const statuses = ["needs_review", "trusted", "provisional", "rejected"];
export default function App() {
const [registry, setRegistry] = useState({ workspaces: [], recent_workspace_ids: [] });
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([]);
const [message, setMessage] = useState("Connecting to local Didactopus bridge...");
async function loadRegistry() {
const res = await fetch(`${API}/api/workspaces`);
const data = await res.json();
setRegistry(data);
if (!session) setMessage("Choose, create, preview, or import a workspace.");
}
useEffect(() => {
loadRegistry().catch(() => setMessage("Could not connect to local review bridge. Start the Python bridge service first."));
}, []);
async function createWorkspace() {
if (!workspaceId || !workspaceTitle) return;
await fetch(`${API}/api/workspaces/create`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle })
});
await loadRegistry();
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);
setMessage(data.ok ? "Import preview ready." : "Import preview found blocking errors.");
}
async function importWorkspace() {
if (!workspaceId || !importSource) return;
const res = await fetch(`${API}/api/workspaces/import`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
workspace_id: workspaceId,
title: workspaceTitle || workspaceId,
source_dir: importSource,
allow_overwrite: allowOverwrite
})
});
const data = await res.json();
if (!data.ok) {
setMessage(data.error || "Import failed.");
return;
}
await loadRegistry();
await openWorkspace(workspaceId);
setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
}
async function openWorkspace(id) {
const res = await fetch(`${API}/api/workspaces/open`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ workspace_id: id })
});
const opened = await res.json();
if (!opened.ok) {
setMessage("Could not open workspace.");
return;
}
const sessionRes = await fetch(`${API}/api/load`);
const sessionData = await sessionRes.json();
setSession(sessionData.session);
setSelectedId(sessionData.session?.draft_pack?.concepts?.[0]?.concept_id || "");
setPendingActions([]);
setMessage(`Opened workspace ${id}.`);
await loadRegistry();
}
const selected = useMemo(() => {
if (!session) return null;
return session.draft_pack.concepts.find((c) => c.concept_id === selectedId) || null;
}, [session, selectedId]);
function queueAction(action) {
setPendingActions((prev) => [...prev, action]);
}
function patchConcept(conceptId, patch, rationale) {
if (!session) return;
const concepts = session.draft_pack.concepts.map((c) =>
c.concept_id === conceptId ? { ...c, ...patch } : c
);
setSession({ ...session, draft_pack: { ...session.draft_pack, concepts } });
if (patch.status !== undefined) queueAction({ action_type: "set_status", target: conceptId, payload: { status: patch.status }, rationale });
if (patch.title !== undefined) queueAction({ action_type: "edit_title", target: conceptId, payload: { title: patch.title }, rationale });
if (patch.description !== undefined) queueAction({ action_type: "edit_description", target: conceptId, payload: { description: patch.description }, rationale });
if (patch.prerequisites !== undefined) queueAction({ action_type: "edit_prerequisites", target: conceptId, payload: { prerequisites: patch.prerequisites }, rationale });
if (patch.notes !== undefined) queueAction({ action_type: "edit_notes", target: conceptId, payload: { notes: patch.notes }, rationale });
}
function resolveConflict(conflict) {
if (!session) return;
setSession({
...session,
draft_pack: { ...session.draft_pack, conflicts: session.draft_pack.conflicts.filter((c) => c !== conflict) }
});
queueAction({ action_type: "resolve_conflict", target: "", payload: { conflict }, rationale: "Resolved in UI" });
}
async function saveChanges() {
if (!pendingActions.length) {
setMessage("No pending changes to save.");
return;
}
const res = await fetch(`${API}/api/save`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ actions: pendingActions })
});
const data = await res.json();
setSession(data.session);
setPendingActions([]);
setMessage("Saved review state.");
}
async function exportPromoted() {
const res = await fetch(`${API}/api/export`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({})
});
const data = await res.json();
setMessage(`Exported promoted pack to ${data.promoted_pack_dir}`);
}
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus Graph QA</h1>
<p>
Reduce the activation-energy hump from generated draft packs to curated review workspaces
by surfacing prerequisite-graph problems before import.
</p>
<div className="small">{message}</div>
</div>
<div className="hero-actions">
<button onClick={saveChanges}>Save Review State</button>
<button onClick={exportPromoted} disabled={!session}>Export Promoted Pack</button>
</div>
</header>
<section className="summary-grid">
<div className="card">
<h2>Create Workspace</h2>
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
<label>Title<input value={workspaceTitle} onChange={(e) => setWorkspaceTitle(e.target.value)} /></label>
<button onClick={createWorkspace}>Create</button>
</div>
<div className="card">
<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>
<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>
<ul>{registry.recent_workspace_ids.map((id) => <li key={id}><button onClick={() => openWorkspace(id)}>{id}</button></li>)}</ul>
</div>
<div className="card">
<h2>All Workspaces</h2>
<ul>{registry.workspaces.map((ws) => <li key={ws.workspace_id}><button onClick={() => openWorkspace(ws.workspace_id)}>{ws.title} ({ws.workspace_id})</button></li>)}</ul>
</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>Concepts:</strong> {importPreview.summary?.concept_count ?? "-"}</div>
</div>
<div className="card">
<h2>Validation 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>Semantic QA Warnings</h2>
<ul>{(importPreview.semantic_warnings || []).length ? importPreview.semantic_warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
<div className="card">
<h2>Graph QA Warnings</h2>
<ul>{(importPreview.graph_warnings || []).length ? importPreview.graph_warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
</section>
)}
{session && (
<main className="layout">
<aside className="sidebar">
<h2>Concepts</h2>
{session.draft_pack.concepts.map((c) => (
<button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}>
<span>{c.title}</span>
<span className={`status-pill status-${c.status}`}>{c.status}</span>
</button>
))}
</aside>
<section className="content">
{selected && (
<div className="card">
<h2>Concept Editor</h2>
<label>Title<input value={selected.title} onChange={(e) => patchConcept(selected.concept_id, { title: e.target.value }, "Edited title")} /></label>
<label>Status
<select value={selected.status} onChange={(e) => patchConcept(selected.concept_id, { status: e.target.value }, "Changed trust status")}>
{statuses.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label>Description<textarea rows="6" value={selected.description} onChange={(e) => patchConcept(selected.concept_id, { description: e.target.value }, "Edited description")} /></label>
<label>Prerequisites (comma-separated ids)<input value={(selected.prerequisites || []).join(", ")} onChange={(e) => patchConcept(selected.concept_id, { prerequisites: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) }, "Edited prerequisites")} /></label>
<label>Notes (one per line)<textarea rows="4" value={(selected.notes || []).join("\n")} onChange={(e) => patchConcept(selected.concept_id, { notes: e.target.value.split("\n").filter(Boolean) }, "Edited notes")} /></label>
</div>
)}
</section>
<section className="rightbar">
<div className="card">
<h2>Conflicts</h2>
{session.draft_pack.conflicts.length ? session.draft_pack.conflicts.map((conflict, idx) => (
<div key={idx} className="conflict">
<div>{conflict}</div>
<button onClick={() => resolveConflict(conflict)}>Resolve</button>
</div>
)) : <div className="small">No remaining conflicts.</div>}
</div>
<div className="card">
<h2>Review Flags</h2>
<ul>{session.draft_pack.review_flags.map((flag, idx) => <li key={idx}>{flag}</li>)}</ul>
</div>
</section>
</main>
)}
</div>
);
}

View File

@ -2,4 +2,5 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,22 +1,40 @@
:root { --bg:#f7f8fb; --card:#fff; --text:#1f2430; --muted:#5d6678; --border:#d7dce5; --accent:#2d6cdf; } :root {
* { box-sizing:border-box; } --bg: #f7f8fb;
body { margin:0; font-family:Arial,Helvetica,sans-serif; background:var(--bg); color:var(--text); } --card: #ffffff;
.page { max-width:1500px; margin:0 auto; padding:20px; } --text: #1f2430;
.hero { background:var(--card); border:1px solid var(--border); border-radius:20px; padding:20px; display:flex; justify-content:space-between; gap:20px; align-items:flex-start; } --muted: #5d6678;
.hero-actions, .button-row { display:flex; gap:10px; flex-wrap:wrap; } --border: #d7dce5;
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; } --accent: #2d6cdf;
.summary-grid, .preview-grid { margin-top:16px; display:grid; grid-template-columns:repeat(4, 1fr); gap:16px; } }
.layout { margin-top:16px; display:grid; grid-template-columns:290px 1fr 360px; gap:16px; } * { box-sizing: border-box; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:16px; } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
.sidebar,.content,.rightbar { display:flex; flex-direction:column; gap:16px; } .page { max-width: 1500px; margin: 0 auto; padding: 20px; }
.concept-btn { width:100%; text-align:left; display:flex; justify-content:space-between; gap:8px; margin-bottom:10px; } .hero { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 20px; display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; }
.concept-btn.active { border-color:var(--accent); box-shadow:0 0 0 2px rgba(45,108,223,0.08); } .hero h1 { margin-top: 0; }
.status-pill { font-size:12px; padding:4px 8px; border-radius:999px; border:1px solid var(--border); white-space:nowrap; } .hero-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.status-trusted { background:#e7f7ec; } .status-provisional { background:#fff6df; } .status-rejected { background:#fde9e9; } .status-needs_review { background:#eef2f7; } button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
label { display:block; font-weight:600; margin-bottom:12px; } button:hover { border-color: var(--accent); }
input, textarea, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } .summary-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.small { color:var(--muted); } .preview-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.conflict { border-top:1px solid var(--border); padding-top:12px; margin-top:12px; } .layout { margin-top: 16px; display: grid; grid-template-columns: 290px 1fr 360px; gap: 16px; }
ul { padding-left:18px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 16px; }
.checkline { display:flex; gap:10px; align-items:center; } .checkline input { width:auto; margin-top:0; } .sidebar, .content, .rightbar { display: flex; flex-direction: column; gap: 16px; }
@media (max-width:1100px) { .summary-grid, .preview-grid { grid-template-columns:repeat(2,1fr); } .layout { grid-template-columns:1fr; } } .concept-btn { width: 100%; text-align: left; display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.concept-btn.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.08); }
.status-pill { font-size: 12px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--border); white-space: nowrap; }
.status-trusted { background: #e7f7ec; }
.status-provisional { background: #fff6df; }
.status-rejected { background: #fde9e9; }
.status-needs_review { background: #eef2f7; }
label { display: block; font-weight: 600; margin-bottom: 12px; }
input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; background: white; }
.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, .preview-grid { grid-template-columns: repeat(2, 1fr); }
.layout { grid-template-columns: 1fr; }
}