Apply ZIP update: 110-didactopus-curriculum-path-quality-update.zip [2026-03-14T13:18:59]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent ac4c975100
commit 6428cfb869
30 changed files with 420 additions and 418 deletions

View File

@ -3,23 +3,19 @@ concepts:
title: Foundations title: Foundations
description: Broad foundations topic with many ideas. description: Broad foundations topic with many ideas.
prerequisites: [] prerequisites: []
mastery_signals:
- Explain core foundations.
- id: c2 - id: c2
title: Methods title: Methods
description: Methods concept with sparse explicit assessment. description: Methods concept.
prerequisites: [c1] prerequisites: [c1]
mastery_signals:
- Use methods appropriately.
- id: c3 - id: c3
title: Advanced Inference title: Advanced Inference
description: Advanced inference topic. description: Advanced inference topic.
prerequisites: [c1, c2] prerequisites: [c1, c2]
mastery_signals:
- Critique advanced inference.
- id: c4 - id: c4
title: Detached Topic title: Detached Topic
description: Detached topic with no assessment coverage. description: Detached topic with no assessment coverage.
prerequisites: [] prerequisites: []
mastery_signals: - id: c5
- Explain detached topic. title: Capstone Topic
description: Final synthesis topic.
prerequisites: [c3]

View File

@ -1,6 +1,6 @@
projects: projects:
- id: narrow-project - id: early-capstone
title: Final Memo title: Capstone Final Project
prerequisites: [c1] prerequisites: [c1]
deliverables: deliverables:
- brief memo - short memo

View File

@ -7,3 +7,7 @@ stages:
title: Tiny Bridge title: Tiny Bridge
concepts: [c4] concepts: [c4]
checkpoint: [] checkpoint: []
- id: stage-3
title: Ending
concepts: [c5]
checkpoint: []

View File

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

View File

@ -1,5 +1,6 @@
review: review:
default_reviewer: "Wesley R. Elsberry" default_reviewer: "Wesley R. Elsberry"
write_promoted_pack: true
bridge: bridge:
host: "127.0.0.1" host: "127.0.0.1"
port: 8765 port: 8765

View File

@ -1,3 +1,9 @@
# FAQ # FAQ
This layer does not prove pedagogical adequacy. It is a heuristic signal layer for likely misalignments. ## Why add curriculum path quality?
Because even a good concept graph does not automatically produce a good learning path.
## Does this replace human pedagogical judgment?
No. It is a heuristic review aid meant to reduce preventable friction.

View File

@ -3,17 +3,11 @@ concepts:
title: Bayes Prior title: Bayes Prior
description: Prior beliefs before evidence in a probabilistic model. description: Prior beliefs before evidence in a probabilistic model.
prerequisites: [] prerequisites: []
mastery_signals:
- 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.
prerequisites: [bayes-prior] prerequisites: [bayes-prior]
mastery_signals:
- Compare prior and posterior beliefs.
- id: model-checking - id: model-checking
title: Model Checking title: Model Checking
description: Evaluate whether model assumptions and fit remain plausible. description: Evaluate whether model assumptions and fit remain plausible.
prerequisites: [bayes-posterior] prerequisites: [bayes-posterior]
mastery_signals:
- Critique a model fit.

View File

@ -3,5 +3,4 @@ projects:
title: Final Model Critique title: Final Model Critique
prerequisites: [bayes-prior, bayes-posterior, model-checking] prerequisites: [bayes-prior, bayes-posterior, model-checking]
deliverables: deliverables:
- short critique report - short report
- explanation of prior and posterior updates

View File

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

View File

@ -5,13 +5,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
description = "Didactopus: coverage and alignment analysis" description = "Didactopus: curriculum path quality analysis"
readme = "README.md" 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] [project.optional-dependencies]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0", "ruff>=0.6"]
[project.scripts] [project.scripts]
didactopus-review-bridge = "didactopus.review_bridge_server:main" didactopus-review-bridge = "didactopus.review_bridge_server:main"

View File

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

View File

@ -4,6 +4,7 @@ import yaml
class ReviewConfig(BaseModel): class ReviewConfig(BaseModel):
default_reviewer: str = "Unknown Reviewer" default_reviewer: str = "Unknown Reviewer"
write_promoted_pack: bool = True
class BridgeConfig(BaseModel): class BridgeConfig(BaseModel):
host: str = "127.0.0.1" host: str = "127.0.0.1"
@ -17,4 +18,5 @@ class AppConfig(BaseModel):
def load_config(path: str | Path) -> AppConfig: def load_config(path: str | Path) -> AppConfig:
with open(path, "r", encoding="utf-8") as handle: with open(path, "r", encoding="utf-8") as handle:
return AppConfig.model_validate(yaml.safe_load(handle) or {}) data = yaml.safe_load(handle) or {}
return AppConfig.model_validate(data)

View File

@ -1,4 +1,51 @@
from __future__ import annotations
from collections import defaultdict, deque
from .pack_validator import load_pack_artifacts from .pack_validator import load_pack_artifacts
def graph_qa_for_pack(source_dir):
def graph_qa_for_pack(source_dir) -> dict:
loaded = load_pack_artifacts(source_dir) loaded = load_pack_artifacts(source_dir)
return {"warnings": [], "summary": {"graph_warning_count": 0}} if loaded["ok"] else {"warnings": [], "summary": {"graph_warning_count": 0}} 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 = []
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 and 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))
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.")
for cid in concept_ids:
if len(outgoing[cid]) >= 3:
warnings.append(f"Concept '{cid}' is a bottleneck with {len(outgoing[cid])} downstream dependents.")
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.")
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}
while q:
node = q.popleft()
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}
return {"warnings": warnings, "summary": summary}

View File

@ -1,17 +1,16 @@
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 .path_quality_qa import path_quality_for_pack
from .coverage_alignment_qa import coverage_alignment_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": []}
graph = graph_qa_for_pack(source_dir) if result["ok"] else {"warnings": []} graph = graph_qa_for_pack(source_dir) if result["ok"] else {"warnings": []}
pathq = path_quality_for_pack(source_dir) if result["ok"] else {"warnings": []} pathq = path_quality_for_pack(source_dir) if result["ok"] else {"warnings": []}
coverage = coverage_alignment_for_pack(source_dir) if result["ok"] else {"warnings": []}
return ImportPreview( return ImportPreview(
source_dir=str(Path(source_dir)), source_dir=str(Path(source_dir)),
workspace_id=workspace_id, workspace_id=workspace_id,
@ -23,5 +22,4 @@ def preview_draft_pack_import(source_dir, workspace_id, overwrite_required=False
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"]), path_warnings=list(pathq["warnings"]),
coverage_warnings=list(coverage["warnings"]),
) )

View File

@ -1,7 +1,8 @@
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"] REQUIRED_FILES = ["pack.yaml", "concepts.yaml", "roadmap.yaml", "projects.yaml", "rubrics.yaml"]
def _safe_load_yaml(path: Path, errors: list[str], label: str): def _safe_load_yaml(path: Path, errors: list[str], label: str):
try: try:
@ -10,73 +11,90 @@ def _safe_load_yaml(path: Path, errors: list[str], label: str):
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}"], "warnings": [], "summary": {}, "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}"], "warnings": [], "summary": {}, "artifacts": {}} return {"ok": False, "errors": [f"Source path is not a directory: {source}"], "warnings": [], "summary": {}, "artifacts": {}}
for fn in REQUIRED_FILES: for filename in REQUIRED_FILES:
if not (source/fn).exists(): if not (source / filename).exists():
errors.append(f"Missing required file: {fn}") errors.append(f"Missing required file: {filename}")
if errors: if errors:
return {"ok": False, "errors": errors, "warnings": [], "summary": {}, "artifacts": {}} return {"ok": False, "errors": errors, "warnings": [], "summary": {}, "artifacts": {}}
return { pack_data = _safe_load_yaml(source / "pack.yaml", errors, "pack.yaml")
"ok": True, "errors": [], "warnings": [], "summary": {}, concepts_data = _safe_load_yaml(source / "concepts.yaml", errors, "concepts.yaml")
"artifacts": { roadmap_data = _safe_load_yaml(source / "roadmap.yaml", errors, "roadmap.yaml")
"pack": _safe_load_yaml(source/"pack.yaml", errors, "pack.yaml"), projects_data = _safe_load_yaml(source / "projects.yaml", errors, "projects.yaml")
"concepts": _safe_load_yaml(source/"concepts.yaml", errors, "concepts.yaml"), rubrics_data = _safe_load_yaml(source / "rubrics.yaml", errors, "rubrics.yaml")
"roadmap": _safe_load_yaml(source/"roadmap.yaml", errors, "roadmap.yaml"), return {"ok": len(errors) == 0, "errors": errors, "warnings": [], "summary": {}, "artifacts": {"pack": pack_data, "concepts": concepts_data, "roadmap": roadmap_data, "projects": projects_data, "rubrics": rubrics_data}}
"projects": _safe_load_yaml(source/"projects.yaml", errors, "projects.yaml"),
"rubrics": _safe_load_yaml(source/"rubrics.yaml", errors, "rubrics.yaml"),
}
}
def validate_pack_directory(source_dir): def validate_pack_directory(source_dir: str | Path) -> dict:
loaded = load_pack_artifacts(source_dir) loaded = load_pack_artifacts(source_dir)
errors = list(loaded["errors"]); warnings = list(loaded["warnings"]); summary = dict(loaded["summary"]) errors = list(loaded["errors"])
warnings = list(loaded["warnings"])
summary = dict(loaded["summary"])
if not loaded["ok"]: if not loaded["ok"]:
return {"ok": False, "errors": errors, "warnings": warnings, "summary": summary} return {"ok": False, "errors": errors, "warnings": warnings, "summary": summary}
pack = loaded["artifacts"]["pack"]; concepts = loaded["artifacts"]["concepts"].get("concepts", []) or [] pack_data = loaded["artifacts"]["pack"]
roadmap = loaded["artifacts"]["roadmap"].get("stages", []) or [] concepts_data = loaded["artifacts"]["concepts"]
projects = loaded["artifacts"]["projects"].get("projects", []) or [] roadmap_data = loaded["artifacts"]["roadmap"]
rubrics = loaded["artifacts"]["rubrics"].get("rubrics", []) or [] projects_data = loaded["artifacts"]["projects"]
for field in ["name","display_name","version"]: rubrics_data = loaded["artifacts"]["rubrics"]
if field not in pack: for field in ["name", "display_name", "version"]:
if field not in pack_data:
warnings.append(f"pack.yaml has no '{field}' field.") warnings.append(f"pack.yaml has no '{field}' field.")
ids = [] concepts = concepts_data.get("concepts", [])
for i, c in enumerate(concepts): roadmap_stages = roadmap_data.get("stages", [])
cid = c.get("id","") 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: if not cid:
errors.append(f"Concept at index {i} has no id.") errors.append(f"Concept at index {idx} has no id.")
else: else:
ids.append(cid) concept_ids.append(cid)
if len(str(c.get("description","")).strip()) < 12: if not concept.get("title"):
warnings.append(f"Concept '{cid or i}' has a very thin description.") warnings.append(f"Concept '{cid or idx}' has no title.")
seen = set() desc = str(concept.get("description", "") or "")
for cid in ids: if len(desc.strip()) < 12:
if cid in seen: warnings.append(f"Concept '{cid or idx}' has a very thin description.")
errors.append(f"Duplicate concept id: {cid}") seen = set(); dups = set()
for cid in concept_ids:
if cid in seen: dups.add(cid)
seen.add(cid) seen.add(cid)
idset = set(ids) for cid in sorted(dups):
for stage in roadmap: 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 []: for cid in stage.get("concepts", []) or []:
if cid not in idset: if cid not in concept_id_set:
errors.append(f"roadmap.yaml references missing concept id: {cid}") errors.append(f"roadmap.yaml references missing concept id: {cid}")
for project in projects: for project in projects:
if not project.get("id"):
warnings.append("A project entry has no id.")
for cid in project.get("prerequisites", []) or []: for cid in project.get("prerequisites", []) or []:
if cid not in idset: if cid not in concept_id_set:
errors.append(f"projects.yaml references missing prerequisite concept id: {cid}") errors.append(f"projects.yaml references missing prerequisite concept id: {cid}")
for i, rubric in enumerate(rubrics): for idx, rubric in enumerate(rubrics):
crit = rubric.get("criteria", [])
if not rubric.get("id"): if not rubric.get("id"):
warnings.append(f"Rubric at index {i} has no id.") warnings.append(f"Rubric at index {idx} has no id.")
if crit is None: criteria = rubric.get("criteria", [])
warnings.append(f"Rubric '{rubric.get('id', i)}' has null criteria.") if criteria is None:
elif isinstance(crit, list) and len(crit) == 0: warnings.append(f"Rubric '{rubric.get('id', idx)}' has null criteria.")
warnings.append(f"Rubric '{rubric.get('id', i)}' has empty criteria.") elif isinstance(criteria, list) and len(criteria) == 0:
elif not isinstance(crit, list): warnings.append(f"Rubric '{rubric.get('id', idx)}' has empty criteria.")
errors.append(f"Rubric '{rubric.get('id', i)}' criteria is not a list.") elif not isinstance(criteria, list):
summary = {"pack_name": pack.get("name",""), "display_name": pack.get("display_name",""), "version": pack.get("version",""), "concept_count": len(concepts), "roadmap_stage_count": len(roadmap), "project_count": len(projects), "rubric_count": len(rubrics)} errors.append(f"Rubric '{rubric.get('id', idx)}' criteria is not a list.")
return {"ok": len(errors)==0, "errors": errors, "warnings": warnings, "summary": summary} 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

@ -1,13 +1,14 @@
from __future__ import annotations
import re import re
from statistics import mean from statistics import mean
from .pack_validator import load_pack_artifacts from .pack_validator import load_pack_artifacts
CAPSTONE_HINTS = {"capstone","final","comprehensive","culminating"} CAPSTONE_HINTS = {"capstone", "final", "comprehensive", "culminating"}
def tokenize(text: str) -> set[str]: def tokenize(text: str) -> set[str]:
return {t for t in re.sub(r"[^a-z0-9]+", " ", str(text).lower()).split() if t} return {t for t in re.sub(r"[^a-z0-9]+", " ", text.lower()).split() if t}
def path_quality_for_pack(source_dir): def path_quality_for_pack(source_dir) -> dict:
loaded = load_pack_artifacts(source_dir) loaded = load_pack_artifacts(source_dir)
if not loaded["ok"]: if not loaded["ok"]:
return {"warnings": [], "summary": {"path_warning_count": 0}} return {"warnings": [], "summary": {"path_warning_count": 0}}
@ -15,36 +16,49 @@ def path_quality_for_pack(source_dir):
roadmap = loaded["artifacts"]["roadmap"].get("stages", []) or [] roadmap = loaded["artifacts"]["roadmap"].get("stages", []) or []
projects = loaded["artifacts"]["projects"].get("projects", []) or [] projects = loaded["artifacts"]["projects"].get("projects", []) or []
concept_by_id = {c.get("id"): c for c in concepts if c.get("id")} concept_by_id = {c.get("id"): c for c in concepts if c.get("id")}
project_prereq_ids = {cid for p in projects for cid in (p.get("prerequisites", []) or [])} project_prereq_ids = set()
warnings = []; stage_sizes = []; stage_prereq_loads = []; assessed = set(project_prereq_ids) for p in projects:
for cid in p.get("prerequisites", []) or []:
project_prereq_ids.add(cid)
warnings = []
stage_sizes = []; stage_prereq_loads = []; assessed_ids = set(project_prereq_ids)
for idx, stage in enumerate(roadmap): for idx, stage in enumerate(roadmap):
sc = stage.get("concepts", []) or []; cp = stage.get("checkpoint", []) or [] stage_concepts = stage.get("concepts", []) or []
stage_sizes.append(len(sc)) checkpoints = stage.get("checkpoint", []) or []
if len(sc) == 0: warnings.append(f"Roadmap stage '{stage.get('title', idx)}' has no concepts.") stage_sizes.append(len(stage_concepts))
if len(cp) == 0: warnings.append(f"Roadmap stage '{stage.get('title', idx)}' has no checkpoint activity.") if len(stage_concepts) == 0:
cp_tokens = tokenize(' '.join(str(x) for x in cp)) warnings.append(f"Roadmap stage '{stage.get('title', idx)}' has no concepts.")
for cid in sc: if len(checkpoints) == 0:
if tokenize(concept_by_id.get(cid, {}).get("title","")) & cp_tokens: warnings.append(f"Roadmap stage '{stage.get('title', idx)}' has no checkpoint activity.")
assessed.add(cid) cp_tokens = tokenize(' '.join(str(x) for x in checkpoints))
stage_prereq_loads.append(sum(len(concept_by_id.get(cid, {}).get("prerequisites", []) or []) for cid in sc)) for cid in stage_concepts:
title_tokens = tokenize(concept_by_id.get(cid, {}).get("title", ""))
if title_tokens and (title_tokens & cp_tokens):
assessed_ids.add(cid)
stage_prereq_loads.append(sum(len(concept_by_id.get(cid, {}).get("prerequisites", []) or []) for cid in stage_concepts))
for cid in concept_by_id: for cid in concept_by_id:
if cid not in assessed: warnings.append(f"Concept '{cid}' is not visibly assessed by checkpoints or project prerequisites.") if cid not in assessed_ids:
warnings.append(f"Concept '{cid}' is not visibly assessed by checkpoints or project prerequisites.")
for idx, project in enumerate(projects): for idx, project in enumerate(projects):
if tokenize(project.get("title","")) & CAPSTONE_HINTS and len(roadmap) >= 3 and idx == 0: if tokenize(project.get("title", "")) & CAPSTONE_HINTS and len(roadmap) >= 3 and idx == 0:
warnings.append(f"Project '{project.get('title')}' looks capstone-like but appears very early in the project list.") warnings.append(f"Project '{project.get('title')}' looks capstone-like but appears very early in the project list.")
if roadmap: if roadmap:
for idx in range(max(0, len(roadmap)-2), len(roadmap)): late_start = max(0, len(roadmap) - 2)
stage = roadmap[idx]; sc = stage.get("concepts", []) or []; cp = stage.get("checkpoint", []) or [] for idx in range(late_start, len(roadmap)):
linked = any(cid in project_prereq_ids for cid in sc) stage = roadmap[idx]; stage_concepts = stage.get("concepts", []) or []; checkpoints = stage.get("checkpoint", []) or []
if sc and len(cp) == 0 and not linked: linked_to_project = any(cid in project_prereq_ids for cid in stage_concepts)
if stage_concepts and len(checkpoints) == 0 and not linked_to_project:
warnings.append(f"Late roadmap stage '{stage.get('title', idx)}' may be a dead end: no checkpoints and no project linkage.") warnings.append(f"Late roadmap stage '{stage.get('title', idx)}' may be a dead end: no checkpoints and no project linkage.")
if stage_sizes: if stage_sizes:
avg = mean(stage_sizes) avg_size = mean(stage_sizes)
for idx, size in enumerate(stage_sizes): for idx, size in enumerate(stage_sizes):
title = roadmap[idx].get("title", idx) title = roadmap[idx].get("title", idx)
if avg > 0 and size >= max(4, 2.5 * avg): warnings.append(f"Roadmap stage '{title}' is unusually large relative to other stages.") if avg_size > 0 and size >= max(4, 2.5 * avg_size):
if len(roadmap) >= 3 and size == 1: warnings.append(f"Roadmap stage '{title}' is unusually small and may need merging or support concepts.") warnings.append(f"Roadmap stage '{title}' is unusually large relative to other stages.")
if len(roadmap) >= 3 and size == 1:
warnings.append(f"Roadmap stage '{title}' is unusually small and may need merging or support concepts.")
for idx in range(1, len(stage_prereq_loads)): for idx in range(1, len(stage_prereq_loads)):
if stage_prereq_loads[idx] >= stage_prereq_loads[idx-1] + 3: if stage_prereq_loads[idx] >= stage_prereq_loads[idx - 1] + 3:
warnings.append(f"Roadmap stage '{roadmap[idx].get('title', idx)}' shows an abrupt prerequisite-load jump from the prior stage.") warnings.append(f"Roadmap stage '{roadmap[idx].get('title', idx)}' shows an abrupt prerequisite-load jump from the prior stage.")
return {"warnings": warnings, "summary": {"path_warning_count": len(warnings)}} summary = {"path_warning_count": len(warnings), "stage_count": len(roadmap), "project_count": len(projects), "unassessed_concept_count": sum(1 for cid in concept_by_id if cid not in assessed_ids)}
return {"warnings": warnings, "summary": summary}

View File

@ -1,3 +1,4 @@
from __future__ import annotations
from .review_schema import ReviewAction, ReviewLedgerEntry, ReviewSession from .review_schema import ReviewAction, ReviewLedgerEntry, ReviewSession
def _find_concept(session: ReviewSession, concept_id: str): def _find_concept(session: ReviewSession, concept_id: str):

View File

@ -1,3 +1,4 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
import json import json
from .review_loader import load_draft_pack from .review_loader import load_draft_pack
@ -6,36 +7,27 @@ from .review_actions import apply_action
from .review_export import export_review_state_json, export_promoted_pack from .review_export import export_review_state_json, export_promoted_pack
class ReviewWorkspaceBridge: class ReviewWorkspaceBridge:
def __init__(self, workspace_dir, reviewer="Unknown Reviewer"): def __init__(self, workspace_dir: str | Path, reviewer: str = "Unknown Reviewer") -> None:
self.workspace_dir = Path(workspace_dir) self.workspace_dir = Path(workspace_dir); self.reviewer = reviewer; self.workspace_dir.mkdir(parents=True, exist_ok=True)
self.reviewer = reviewer
self.workspace_dir.mkdir(parents=True, exist_ok=True)
@property @property
def draft_pack_dir(self): 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): 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): 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):
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"))) return ReviewSession.model_validate(json.loads(self.review_session_path.read_text(encoding="utf-8")))
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): 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):
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)) apply_action(session, session.reviewer, ReviewAction.model_validate(action_dict))
self.save_session(session) self.save_session(session); return session
return session def export_promoted(self) -> ReviewSession:
session = self.load_session(); export_promoted_pack(session, self.promoted_pack_dir); return session
def export_promoted(self):
session = self.load_session()
export_promoted_pack(session, self.promoted_pack_dir)
return session

View File

@ -1,3 +1,4 @@
from __future__ import annotations
import argparse, json import argparse, json
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path from pathlib import Path
@ -5,7 +6,7 @@ from .config import load_config
from .review_bridge import ReviewWorkspaceBridge from .review_bridge import ReviewWorkspaceBridge
from .workspace_manager import WorkspaceManager from .workspace_manager import WorkspaceManager
def json_response(handler, status, payload): def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload, indent=2).encode("utf-8") body = json.dumps(payload, indent=2).encode("utf-8")
handler.send_response(status) handler.send_response(status)
handler.send_header("Content-Type", "application/json") handler.send_header("Content-Type", "application/json")
@ -17,13 +18,13 @@ def json_response(handler, status, payload):
handler.wfile.write(body) handler.wfile.write(body)
class ReviewBridgeHandler(BaseHTTPRequestHandler): class ReviewBridgeHandler(BaseHTTPRequestHandler):
reviewer = "Unknown Reviewer" reviewer: str = "Unknown Reviewer"
workspace_manager = None workspace_manager: WorkspaceManager = None # type: ignore
active_bridge = None active_bridge: ReviewWorkspaceBridge | None = None
active_workspace_id = None active_workspace_id: str | None = None
@classmethod @classmethod
def set_active_workspace(cls, workspace_id): 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
@ -34,7 +35,7 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.path == "/api/workspaces": if self.path == "/api/workspaces":
return json_response(self, 200, self.workspace_manager.list_workspaces().model_dump()) return json_response(self, 200, ReviewBridgeHandler.workspace_manager.list_workspaces().model_dump())
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, 400, {"error": "no active workspace"})
return json_response(self, 200, {"workspace_id": self.active_workspace_id, "session": self.active_bridge.load_session().model_dump()}) return json_response(self, 200, {"workspace_id": self.active_workspace_id, "session": self.active_bridge.load_session().model_dump()})
@ -45,16 +46,16 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler):
payload = json.loads((self.rfile.read(length) if length else b"{}").decode("utf-8") or "{}") payload = json.loads((self.rfile.read(length) if length else b"{}").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(payload["workspace_id"], payload["title"], notes=payload.get("notes", ""))
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()})
return json_response(self, 200, {"ok": True, "workspace": meta.model_dump()})
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"}) 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 self.path == "/api/workspaces/import-preview": if self.path == "/api/workspaces/import-preview":
return json_response(self, 200, self.workspace_manager.preview_import(payload["source_dir"], payload["workspace_id"]).model_dump()) preview = self.workspace_manager.preview_import(payload["source_dir"], payload["workspace_id"])
return json_response(self, 200, preview.model_dump())
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(payload["source_dir"], 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)}) return json_response(self, 404, {"ok": False, "error": str(exc)})
except FileExistsError as exc: except FileExistsError as exc:
@ -72,12 +73,12 @@ class ReviewBridgeHandler(BaseHTTPRequestHandler):
return json_response(self, 200, {"ok": True, "promoted_pack_dir": str(self.active_bridge.promoted_pack_dir), "workspace_id": self.active_workspace_id, "session": session.model_dump()}) return json_response(self, 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(): def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Didactopus local review bridge server with coverage/alignment QA") parser = argparse.ArgumentParser(description="Didactopus local review bridge server with curriculum path quality QA")
p.add_argument("--config", default="configs/config.example.yaml") parser.add_argument("--config", default="configs/config.example.yaml")
return p return parser
def main(): 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
@ -85,6 +86,3 @@ def main():
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,24 +1,34 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
import json, yaml import json, yaml
from .review_schema import ReviewSession from .review_schema import ReviewSession
def export_review_state_json(session: ReviewSession, path): def export_review_state_json(session: ReviewSession, path: str | Path) -> None:
Path(path).write_text(session.model_dump_json(indent=2), encoding="utf-8") Path(path).write_text(session.model_dump_json(indent=2), encoding="utf-8")
def export_promoted_pack(session: ReviewSession, outdir): def export_promoted_pack(session: ReviewSession, outdir: str | Path) -> None:
outdir = Path(outdir); outdir.mkdir(parents=True, exist_ok=True) outdir = Path(outdir)
promoted = dict(session.draft_pack.pack) outdir.mkdir(parents=True, exist_ok=True)
promoted["version"] = str(promoted.get("version", "0.1.0-draft")).replace("-draft","-reviewed") promoted_pack = dict(session.draft_pack.pack)
promoted["curation"] = {"reviewer": session.reviewer, "ledger_entries": len(session.ledger)} promoted_pack["version"] = str(promoted_pack.get("version", "0.1.0-draft")).replace("-draft", "-reviewed")
promoted_pack["curation"] = {"reviewer": session.reviewer, "ledger_entries": len(session.ledger)}
concepts = [] concepts = []
for concept in session.draft_pack.concepts: for concept in session.draft_pack.concepts:
if concept.status == "rejected": if concept.status == "rejected":
continue continue
concepts.append({ concepts.append({
"id": concept.concept_id, "title": concept.title, "description": concept.description, "id": concept.concept_id,
"prerequisites": concept.prerequisites, "mastery_signals": concept.mastery_signals, "title": concept.title,
"status": concept.status, "notes": concept.notes, "mastery_profile": {} "description": concept.description,
"prerequisites": concept.prerequisites,
"mastery_signals": concept.mastery_signals,
"status": concept.status,
"notes": concept.notes,
"mastery_profile": {},
}) })
(outdir/"pack.yaml").write_text(yaml.safe_dump(promoted, sort_keys=False), encoding="utf-8")
(outdir/"concepts.yaml").write_text(yaml.safe_dump({"concepts": concepts}, sort_keys=False), encoding="utf-8") (outdir / "pack.yaml").write_text(yaml.safe_dump(promoted_pack, sort_keys=False), encoding="utf-8")
(outdir/"review_ledger.json").write_text(json.dumps(session.model_dump(), indent=2), encoding="utf-8") (outdir / "concepts.yaml").write_text(yaml.safe_dump({"concepts": concepts}, sort_keys=False), encoding="utf-8")
(outdir / "review_ledger.json").write_text(json.dumps(session.model_dump(), indent=2), encoding="utf-8")
(outdir / "license_attribution.json").write_text(json.dumps(session.draft_pack.attribution, indent=2), encoding="utf-8")

View File

@ -5,20 +5,38 @@ from .review_schema import DraftPackData, ConceptReviewEntry
def load_draft_pack(pack_dir: str | Path) -> DraftPackData: def load_draft_pack(pack_dir: str | Path) -> DraftPackData:
pack_dir = Path(pack_dir) pack_dir = Path(pack_dir)
data = yaml.safe_load((pack_dir / "concepts.yaml").read_text(encoding="utf-8")) or {} concepts_yaml = yaml.safe_load((pack_dir / "concepts.yaml").read_text(encoding="utf-8")) or {}
concepts = [] concepts = []
for item in data.get("concepts", []): for item in concepts_yaml.get("concepts", []):
concepts.append(ConceptReviewEntry( concepts.append(
concept_id=item.get("id",""), ConceptReviewEntry(
title=item.get("title",""), concept_id=item.get("id", ""),
description=item.get("description",""), title=item.get("title", ""),
prerequisites=list(item.get("prerequisites", [])), description=item.get("description", ""),
mastery_signals=list(item.get("mastery_signals", [])), prerequisites=list(item.get("prerequisites", [])),
status=item.get("status","needs_review"), mastery_signals=list(item.get("mastery_signals", [])),
notes=list(item.get("notes", [])), status=item.get("status", "needs_review"),
)) notes=list(item.get("notes", [])),
pack = yaml.safe_load((pack_dir / "pack.yaml").read_text(encoding="utf-8")) if (pack_dir/"pack.yaml").exists() else {} )
attribution = json.loads((pack_dir / "license_attribution.json").read_text(encoding="utf-8")) if (pack_dir/"license_attribution.json").exists() else {} )
def bullets(path):
return [line[2:] for line in path.read_text(encoding="utf-8").splitlines() if line.startswith("- ")] if path.exists() else [] def bullets(path: Path) -> list[str]:
return DraftPackData(pack=pack or {}, concepts=concepts, conflicts=bullets(pack_dir/"conflict_report.md"), review_flags=bullets(pack_dir/"review_report.md"), attribution=attribution) if not path.exists():
return []
return [line[2:] for line in path.read_text(encoding="utf-8").splitlines() if line.startswith("- ")]
pack = {}
if (pack_dir / "pack.yaml").exists():
pack = yaml.safe_load((pack_dir / "pack.yaml").read_text(encoding="utf-8")) or {}
attribution = {}
if (pack_dir / "license_attribution.json").exists():
attribution = json.loads((pack_dir / "license_attribution.json").read_text(encoding="utf-8"))
return DraftPackData(
pack=pack,
concepts=concepts,
conflicts=bullets(pack_dir / "conflict_report.md"),
review_flags=bullets(pack_dir / "review_report.md"),
attribution=attribution,
)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal from typing import Literal
TrustStatus = Literal["trusted","provisional","rejected","needs_review"] TrustStatus = Literal["trusted", "provisional", "rejected", "needs_review"]
class ConceptReviewEntry(BaseModel): class ConceptReviewEntry(BaseModel):
concept_id: str concept_id: str
@ -58,4 +58,3 @@ class ImportPreview(BaseModel):
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) path_warnings: list[str] = Field(default_factory=list)
coverage_warnings: list[str] = Field(default_factory=list)

View File

@ -1,4 +1,50 @@
from __future__ import annotations
import re
from difflib import SequenceMatcher
from .pack_validator import load_pack_artifacts from .pack_validator import load_pack_artifacts
def semantic_qa_for_pack(source_dir):
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) loaded = load_pack_artifacts(source_dir)
return {"warnings": [], "summary": {"semantic_warning_count": 0}} if loaded["ok"] else {"warnings": [], "summary": {"semantic_warning_count": 0}} 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 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()
if current_titles and next_titles and len(current_tokens & next_tokens) == 0:
warnings.append(f"Roadmap transition from stage '{current_stage.get('title')}' to '{next_stage.get('title')}' may lack a bridge concept.")
return {"warnings": warnings, "summary": {"semantic_warning_count": len(warnings), "pack_name": pack.get("name", "")}}

View File

@ -1,37 +1,41 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
from datetime import datetime, UTC from datetime import datetime, UTC
import json, shutil import json, shutil
from .review_schema import WorkspaceMeta, WorkspaceRegistry from .review_schema import WorkspaceMeta, WorkspaceRegistry
from .import_validator import preview_draft_pack_import from .import_validator import preview_draft_pack_import
def utc_now(): def utc_now() -> str:
return datetime.now(UTC).isoformat() return datetime.now(UTC).isoformat()
class WorkspaceManager: class WorkspaceManager:
def __init__(self, registry_path, default_workspace_root): def __init__(self, registry_path: str | Path, default_workspace_root: str | Path) -> None:
self.registry_path = Path(registry_path) self.registry_path = Path(registry_path)
self.default_workspace_root = Path(default_workspace_root) self.default_workspace_root = Path(default_workspace_root)
self.default_workspace_root.mkdir(parents=True, exist_ok=True) self.default_workspace_root.mkdir(parents=True, exist_ok=True)
def load_registry(self): def load_registry(self) -> WorkspaceRegistry:
if self.registry_path.exists(): if self.registry_path.exists():
return WorkspaceRegistry.model_validate(json.loads(self.registry_path.read_text(encoding="utf-8"))) return WorkspaceRegistry.model_validate(json.loads(self.registry_path.read_text(encoding="utf-8")))
return WorkspaceRegistry() return WorkspaceRegistry()
def save_registry(self, registry): def save_registry(self, registry: WorkspaceRegistry) -> None:
self.registry_path.write_text(registry.model_dump_json(indent=2), encoding="utf-8") self.registry_path.write_text(registry.model_dump_json(indent=2), encoding="utf-8")
def list_workspaces(self): def list_workspaces(self) -> WorkspaceRegistry:
return self.load_registry() return self.load_registry()
def create_workspace(self, workspace_id, title, notes=""): def create_workspace(self, workspace_id: str, title: str, notes: str = "") -> WorkspaceMeta:
registry = self.load_registry() registry = self.load_registry()
workspace_dir = self.default_workspace_root / workspace_id workspace_dir = self.default_workspace_root / workspace_id
workspace_dir.mkdir(parents=True, exist_ok=True) workspace_dir.mkdir(parents=True, exist_ok=True)
draft_dir = workspace_dir / "draft_pack" draft_dir = workspace_dir / "draft_pack"
draft_dir.mkdir(parents=True, exist_ok=True) draft_dir.mkdir(parents=True, exist_ok=True)
if not (draft_dir / "pack.yaml").exists(): if not (draft_dir / "pack.yaml").exists():
(draft_dir / "pack.yaml").write_text(f"name: {workspace_id}\ndisplay_name: {title}\nversion: 0.1.0-draft\n", encoding="utf-8") (draft_dir / "pack.yaml").write_text(
f"name: {workspace_id}\ndisplay_name: {title}\nversion: 0.1.0-draft\ndescription: Seed draft pack for workspace {workspace_id}\n",
encoding="utf-8"
)
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)
@ -40,33 +44,32 @@ class WorkspaceManager:
self.save_registry(registry) self.save_registry(registry)
return meta return meta
def touch_recent(self, workspace_id): def touch_recent(self, workspace_id: str) -> WorkspaceMeta | None:
registry = self.load_registry() registry = self.load_registry()
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() ws.last_opened_at = utc_now(); target = ws; break
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)
return target return target
def get_workspace(self, workspace_id): def get_workspace(self, workspace_id: str) -> WorkspaceMeta | None:
for ws in self.load_registry().workspaces: registry = self.load_registry()
if ws.workspace_id == workspace_id: for ws in registry.workspaces:
return ws if ws.workspace_id == workspace_id: return ws
return None return None
def preview_import(self, source_dir, workspace_id): def preview_import(self, source_dir: str | Path, workspace_id: str):
preview = preview_draft_pack_import(source_dir, workspace_id) preview = preview_draft_pack_import(source_dir, workspace_id)
if self.get_workspace(workspace_id) is not None: existing = self.get_workspace(workspace_id)
if existing is not None:
preview.overwrite_required = True preview.overwrite_required = True
preview.warnings.append(f"Workspace '{workspace_id}' already exists and import will overwrite draft_pack.") preview.warnings.append(f"Workspace '{workspace_id}' already exists and import will overwrite draft_pack.")
return preview return preview
def import_draft_pack(self, source_dir, workspace_id, title=None, notes="", allow_overwrite=False): 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) preview = self.preview_import(source_dir, workspace_id)
if not preview.ok: if not preview.ok:
raise ValueError("Draft pack preview failed: " + "; ".join(preview.errors)) raise ValueError("Draft pack preview failed: " + "; ".join(preview.errors))
@ -76,7 +79,8 @@ class WorkspaceManager:
meta = existing if existing is not None else self.create_workspace(workspace_id, title or workspace_id, notes=notes) meta = existing if existing is not None else self.create_workspace(workspace_id, title or workspace_id, notes=notes)
if existing is not None: if existing is not None:
self.touch_recent(workspace_id) self.touch_recent(workspace_id)
target_draft = Path(meta.path) / "draft_pack" workspace_dir = Path(meta.path)
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)

View File

@ -1,11 +1,11 @@
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_coverage_warnings(tmp_path: Path) -> None: def test_preview_includes_path_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("concepts:\n - id: c1\n title: Foundations\n description: foundations description enough\n prerequisites: []\n", encoding="utf-8")
(tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [c1]\n checkpoint: []\n", encoding="utf-8") (tmp_path / "roadmap.yaml").write_text("stages:\n - id: s1\n title: One\n concepts: [c1]\n checkpoint: []\n", encoding="utf-8")
(tmp_path / "projects.yaml").write_text("projects: []\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: Style\n criteria: [formatting]\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.coverage_warnings, list) assert isinstance(preview.path_warnings, list)

View File

@ -6,7 +6,5 @@
<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> <body><div id="root"></div></body>
<div id="root"></div>
</body>
</html> </html>

View File

@ -3,15 +3,7 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {"dev": "vite", "build": "vite build"},
"dev": "vite", "dependencies": {"react": "^18.3.1", "react-dom": "^18.3.1"},
"build": "vite build" "devDependencies": {"vite": "^5.4.0"}
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
} }

View File

@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
const API = "http://127.0.0.1:8765"; const API = "http://127.0.0.1:8765";
const statuses = ["needs_review", "trusted", "provisional", "rejected"]; const statuses = ["needs_review", "trusted", "provisional", "rejected"];
@ -17,147 +16,68 @@ export default function App() {
async function loadRegistry() { async function loadRegistry() {
const res = await fetch(`${API}/api/workspaces`); const res = await fetch(`${API}/api/workspaces`);
const data = await res.json(); setRegistry(await res.json());
setRegistry(data);
if (!session) setMessage("Choose, create, preview, or import a workspace."); 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.")); }, []);
useEffect(() => {
loadRegistry().catch(() => setMessage("Could not connect to local review bridge. Start the Python bridge service first."));
}, []);
async function createWorkspace() { async function createWorkspace() {
if (!workspaceId || !workspaceTitle) return; if (!workspaceId || !workspaceTitle) return;
await fetch(`${API}/api/workspaces/create`, { await fetch(`${API}/api/workspaces/create`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle })});
method: "POST", await loadRegistry(); await openWorkspace(workspaceId);
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle })
});
await loadRegistry();
await openWorkspace(workspaceId);
} }
async function previewImport() { async function previewImport() {
if (!workspaceId || !importSource) return; if (!workspaceId || !importSource) return;
const res = await fetch(`${API}/api/workspaces/import-preview`, { 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 })});
method: "POST", const data = await res.json(); setImportPreview(data); setMessage(data.ok ? "Import preview ready." : "Import preview found blocking errors.");
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() { async function importWorkspace() {
if (!workspaceId || !importSource) return; if (!workspaceId || !importSource) return;
const res = await fetch(`${API}/api/workspaces/import`, { 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 })});
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(); const data = await res.json();
if (!data.ok) { if (!data.ok) { setMessage(data.error || "Import failed."); return; }
setMessage(data.error || "Import failed."); await loadRegistry(); await openWorkspace(workspaceId); setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
return;
}
await loadRegistry();
await openWorkspace(workspaceId);
setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
} }
async function openWorkspace(id) { async function openWorkspace(id) {
const res = await fetch(`${API}/api/workspaces/open`, { const res = await fetch(`${API}/api/workspaces/open`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ workspace_id: id })});
method: "POST", const opened = await res.json(); if (!opened.ok) { setMessage("Could not open workspace."); return; }
headers: {"Content-Type": "application/json"}, const sessionRes = await fetch(`${API}/api/load`); const sessionData = await sessionRes.json();
body: JSON.stringify({ workspace_id: id }) setSession(sessionData.session); setSelectedId(sessionData.session?.draft_pack?.concepts?.[0]?.concept_id || ""); setPendingActions([]); setMessage(`Opened workspace ${id}.`); await loadRegistry();
});
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]);
} }
const selected = useMemo(() => session ? session.draft_pack.concepts.find((c) => c.concept_id === selectedId) || null : null, [session, selectedId]);
function queueAction(action) { setPendingActions((prev) => [...prev, action]); }
function patchConcept(conceptId, patch, rationale) { function patchConcept(conceptId, patch, rationale) {
if (!session) return; if (!session) return;
const concepts = session.draft_pack.concepts.map((c) => const concepts = session.draft_pack.concepts.map((c) => c.concept_id === conceptId ? { ...c, ...patch } : c);
c.concept_id === conceptId ? { ...c, ...patch } : c
);
setSession({ ...session, draft_pack: { ...session.draft_pack, concepts } }); 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.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.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.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.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 }); if (patch.notes !== undefined) queueAction({ action_type: "edit_notes", target: conceptId, payload: { notes: patch.notes }, rationale });
} }
function resolveConflict(conflict) { function resolveConflict(conflict) {
if (!session) return; if (!session) return;
setSession({ setSession({ ...session, draft_pack: { ...session.draft_pack, conflicts: session.draft_pack.conflicts.filter((c) => c !== conflict) } });
...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" }); queueAction({ action_type: "resolve_conflict", target: "", payload: { conflict }, rationale: "Resolved in UI" });
} }
async function saveChanges() { async function saveChanges() {
if (!pendingActions.length) { if (!pendingActions.length) { setMessage("No pending changes to save."); return; }
setMessage("No pending changes to save."); const res = await fetch(`${API}/api/save`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ actions: pendingActions })});
return; const data = await res.json(); setSession(data.session); setPendingActions([]); setMessage("Saved review state.");
}
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() { async function exportPromoted() {
const res = await fetch(`${API}/api/export`, { const res = await fetch(`${API}/api/export`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({})});
method: "POST", const data = await res.json(); setMessage(`Exported promoted pack to ${data.promoted_pack_dir}`);
headers: {"Content-Type": "application/json"},
body: JSON.stringify({})
});
const data = await res.json();
setMessage(`Exported promoted pack to ${data.promoted_pack_dir}`);
} }
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus Full Pack Validation</h1> <h1>Didactopus Curriculum Path QA</h1>
<p> <p>Reduce activation-energy by surfacing roadmap and assessment progression problems before import.</p>
Reduce the activation-energy hump from generated draft packs to curated review workspaces
by previewing structural coherence, warnings, and overwrite risk before import.
</p>
<div className="small">{message}</div> <div className="small">{message}</div>
</div> </div>
<div className="hero-actions"> <div className="hero-actions">
@ -176,12 +96,9 @@ export default function App() {
<div className="card"> <div className="card">
<h2>Preview / Import Draft Pack</h2> <h2>Preview / Import Draft Pack</h2>
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label> <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>Draft Pack Source Directory<input value={importSource} onChange={(e) => setImportSource(e.target.value)} /></label>
<label className="checkline"><input type="checkbox" checked={allowOverwrite} onChange={(e) => setAllowOverwrite(e.target.checked)} /> Allow overwrite of existing workspace draft_pack</label> <label className="checkline"><input type="checkbox" checked={allowOverwrite} onChange={(e) => setAllowOverwrite(e.target.checked)} /> Allow overwrite</label>
<div className="button-row"> <div className="button-row"><button onClick={previewImport}>Preview</button><button onClick={importWorkspace}>Import</button></div>
<button onClick={previewImport}>Preview</button>
<button onClick={importWorkspace}>Import</button>
</div>
</div> </div>
<div className="card"> <div className="card">
<h2>Recent</h2> <h2>Recent</h2>
@ -195,25 +112,10 @@ export default function App() {
{importPreview && ( {importPreview && (
<section className="preview-grid"> <section className="preview-grid">
<div className="card"> <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>
<h2>Import Preview</h2> <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><strong>OK:</strong> {String(importPreview.ok)}</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>
<div><strong>Overwrite Required:</strong> {String(importPreview.overwrite_required)}</div> <div className="card"><h2>Path Quality Warnings</h2><ul>{(importPreview.path_warnings || []).length ? importPreview.path_warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul></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><strong>Roadmap Stages:</strong> {importPreview.summary?.roadmap_stage_count ?? "-"}</div>
<div><strong>Projects:</strong> {importPreview.summary?.project_count ?? "-"}</div>
<div><strong>Rubrics:</strong> {importPreview.summary?.rubric_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>Validation Warnings</h2>
<ul>{(importPreview.warnings || []).length ? importPreview.warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
</section> </section>
)} )}
@ -223,43 +125,23 @@ export default function App() {
<h2>Concepts</h2> <h2>Concepts</h2>
{session.draft_pack.concepts.map((c) => ( {session.draft_pack.concepts.map((c) => (
<button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}> <button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}>
<span>{c.title}</span> <span>{c.title}</span><span className={`status-pill status-${c.status}`}>{c.status}</span>
<span className={`status-pill status-${c.status}`}>{c.status}</span>
</button> </button>
))} ))}
</aside> </aside>
<section className="content"> <section className="content">
{selected && ( {selected && <div className="card">
<div className="card"> <h2>Concept Editor</h2>
<h2>Concept Editor</h2> <label>Title<input value={selected.title} onChange={(e) => patchConcept(selected.concept_id, { title: e.target.value }, "Edited title")} /></label>
<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>Status <label>Description<textarea rows="6" value={selected.description} onChange={(e) => patchConcept(selected.concept_id, { description: e.target.value }, "Edited description")} /></label>
<select value={selected.status} onChange={(e) => patchConcept(selected.concept_id, { status: e.target.value }, "Changed trust status")}> <label>Prerequisites<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>
{statuses.map((s) => <option key={s} value={s}>{s}</option>)} <label>Notes<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>
</select> </div>}
</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>
<section className="rightbar"> <section className="rightbar">
<div className="card"> <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>
<h2>Conflicts</h2> <div className="card"><h2>Review Flags</h2><ul>{session.draft_pack.review_flags.map((flag, idx) => <li key={idx}>{flag}</li>)}</ul></div>
{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> </section>
</main> </main>
)} )}

View File

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