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