Added review UI

This commit is contained in:
welsberr 2026-03-13 07:53:06 -04:00
parent 0656f7bbe8
commit 1d0de94025
26 changed files with 644 additions and 113 deletions

View File

@ -8,6 +8,45 @@
## Recent revisions
### Review workflow
This revision adds a **review UI / curation workflow scaffold** for generated draft packs.
The purpose is to let a human reviewer inspect draft outputs from the course/topic
ingestion pipeline, make explicit curation decisions, and promote a reviewed draft
into a more trusted domain pack.
#### What is included
- review-state schema
- draft-pack loader
- curation action model
- review decision ledger
- promoted-pack writer
- static HTML review UI scaffold
- JSON data export for the UI
- sample curated review session
- sample promoted pack output
#### Core idea
Draft packs should not move directly into trusted use.
Instead, they should pass through a curation workflow where a reviewer can:
- merge concepts
- split concepts
- edit prerequisites
- mark concepts as trusted / provisional / rejected
- resolve conflict flags
- annotate rationale
- promote a curated pack into a reviewed pack
#### Status
This is a scaffold for a local-first workflow.
The HTML UI is static but wired to a concrete JSON review-state model so it can
later be upgraded into a richer SPA or desktop app without changing the data contracts.
### Course-to-course merger
This revision adds two major capabilities:
@ -248,7 +287,3 @@ didactopus/
├── src/didactopus/
└── tests/
```

View File

@ -1,19 +1,5 @@
document_adapters:
allow_pdf: true
allow_docx: true
allow_pptx: true
allow_html: true
allow_markdown: true
allow_text: true
course_ingest:
default_pack_author: "Wesley R. Elsberry"
default_license: "REVIEW-REQUIRED"
min_term_length: 4
max_terms_per_lesson: 8
cross_course:
detect_title_overlaps: true
detect_term_conflicts: true
detect_order_conflicts: true
merge_same_named_lessons: true
review:
default_reviewer: "Wesley R. Elsberry"
allow_provisional_concepts: true
write_promoted_pack: true
write_review_ledger: true

View File

@ -1,25 +1,27 @@
# FAQ
## Why add document adapters now?
## Why add a review UI?
Because real educational material is rarely provided in only one plain-text format.
Because automatically generated packs are draft assets, not final trusted assets.
## Are these full-fidelity parsers?
## What can a reviewer change?
Not yet. The current implementation is a stable scaffold for extraction and normalization.
In this scaffold:
- concept trust status
- prerequisites
- titles
- descriptions
- merge/split intent records
- conflict resolution notes
## Why add cross-course merging?
## Is the UI fully interactive?
Because one course often under-specifies a domain, while multiple sources together can produce a better draft pack.
Not yet. The current version is a static HTML scaffold backed by real JSON data models.
## Does the merger resolve every concept conflict automatically?
## Why keep a review ledger?
No. It produces a merged draft plus a conflict report for human review.
To preserve provenance and make curation decisions auditable.
## What kinds of issues are flagged?
## Does promotion mean certification?
Examples:
- repeated concepts with different names
- same term used with different local contexts
- courses that introduce topics in conflicting orders
- weak or thin concept descriptions
No. Promotion means "reviewed and improved for Didactopus use," not formal certification.

41
docs/review-workflow.md Normal file
View File

@ -0,0 +1,41 @@
# Review Workflow
The Didactopus review workflow sits between draft-pack generation and trusted pack use.
## Why it exists
Automated ingestion is useful, but draft packs are not reliable enough to trust blindly.
Human curation is needed to:
- fix mistaken prerequisite edges
- merge duplicate or near-duplicate concepts
- split over-broad concepts
- remove weak concept candidates
- classify concepts by trust level
- resolve terminology conflicts
## Workflow stages
1. load draft pack
2. inspect concepts, prerequisites, conflicts, and review flags
3. apply curation actions
4. record rationale in a review ledger
5. generate promoted pack
6. preserve provenance
## Trust statuses
Current scaffold statuses:
- `trusted`
- `provisional`
- `rejected`
- `needs_review`
## Promotion
Promotion does not erase provenance.
The promoted pack keeps:
- curation metadata
- source attribution
- review ledger
- timestamps and reviewer identity fields

View File

@ -0,0 +1,26 @@
concepts:
- id: descriptive-statistics
title: Descriptive Statistics
description: Measures of center and spread.
prerequisites: []
mastery_signals:
- Explain mean, median, and variance.
mastery_profile: {}
- id: probability-basics
title: Probability Basics
description: Basic event probability and conditional probability.
prerequisites:
- descriptive-statistics
mastery_signals:
- Compute a simple conditional probability.
mastery_profile: {}
- id: prior-and-posterior
title: Prior and Posterior
description: Beliefs before and after evidence.
prerequisites:
- probability-basics
mastery_signals:
- Compare prior and posterior beliefs.
mastery_profile: {}

View File

@ -0,0 +1,4 @@
# Conflict Report
- Key term 'prior' appears in multiple lesson contexts.
- Lesson 'prior and posterior' was merged from multiple sources; review ordering assumptions.

View File

@ -0,0 +1,15 @@
{
"rights_note": "REVIEW REQUIRED",
"sources": [
{
"source_path": "examples/intro_bayes_outline.md",
"source_type": "markdown",
"title": "Intro Bayes Outline"
},
{
"source_path": "examples/intro_bayes_lecture.html",
"source_type": "html",
"title": "Intro Bayes Lecture"
}
]
}

View File

@ -0,0 +1,13 @@
name: introductory-bayesian-inference
display_name: Introductory Bayesian Inference
version: 0.1.0-draft
schema_version: "1"
didactopus_min_version: 0.1.0
didactopus_max_version: 0.9.99
description: Draft topic pack generated from multi-course inputs.
author: Wesley R. Elsberry
license: REVIEW-REQUIRED
dependencies: []
overrides: []
profile_templates: {}
cross_pack_links: []

View File

@ -0,0 +1,4 @@
# Review Report
- Module 'Bayesian Updating' appears to contain project-like material; review project extraction.
- Concept 'Prior and Posterior' may be too broad and may need splitting.

View File

@ -0,0 +1 @@
<!DOCTYPE html><html><body><h1>Didactopus Review UI</h1><p>Static scaffold.</p></body></html>

View File

@ -0,0 +1,22 @@
concepts:
- id: descriptive-statistics
title: Descriptive Statistics
description: Measures of center and spread.
prerequisites: []
mastery_signals:
- Explain mean, median, and variance.
status: trusted
notes:
- Reviewed in initial curation pass.
mastery_profile: {}
- id: probability-basics
title: Probability Basics
description: Basic event probability and conditional probability.
prerequisites:
- descriptive-statistics
mastery_signals:
- Compute a simple conditional probability.
status: provisional
notes: []
mastery_profile: {}

View File

@ -0,0 +1,6 @@
name: introductory-bayesian-inference
display_name: Introductory Bayesian Inference
version: 0.1.0-reviewed
curation:
reviewer: Wesley R. Elsberry
ledger_entries: 2

View File

@ -0,0 +1,4 @@
{
"reviewer": "Wesley R. Elsberry",
"entries": 2
}

View File

@ -0,0 +1,42 @@
{
"reviewer": "Wesley R. Elsberry",
"pack": {
"name": "introductory-bayesian-inference",
"version": "0.1.0-draft"
},
"concepts": [
{
"concept_id": "descriptive-statistics",
"title": "Descriptive Statistics",
"status": "trusted",
"prerequisites": [],
"description": "Measures of center and spread.",
"mastery_signals": [
"Explain mean, median, and variance."
],
"notes": [
"Reviewed in initial curation pass."
]
},
{
"concept_id": "probability-basics",
"title": "Probability Basics",
"status": "provisional",
"prerequisites": [
"descriptive-statistics"
],
"description": "Basic event probability and conditional probability.",
"mastery_signals": [
"Compute a simple conditional probability."
],
"notes": []
}
],
"conflicts": [
"Lesson 'prior and posterior' was merged from multiple sources; review ordering assumptions."
],
"review_flags": [
"Concept 'Prior and Posterior' may be too broad and may need splitting."
],
"ledger": []
}

View File

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "didactopus"
version = "0.1.0"
description = "Didactopus: document-adapter and cross-course merger scaffold"
description = "Didactopus: draft-pack review workflow scaffold"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
@ -16,7 +16,7 @@ dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
dev = ["pytest>=8.0", "ruff>=0.6"]
[project.scripts]
didactopus-topic-ingest = "didactopus.main:main"
didactopus-review = "didactopus.main:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -3,33 +3,15 @@ from pydantic import BaseModel, Field
import yaml
class DocumentAdaptersConfig(BaseModel):
allow_pdf: bool = True
allow_docx: bool = True
allow_pptx: bool = True
allow_html: bool = True
allow_markdown: bool = True
allow_text: bool = True
class CourseIngestConfig(BaseModel):
default_pack_author: str = "Unknown"
default_license: str = "REVIEW-REQUIRED"
min_term_length: int = 4
max_terms_per_lesson: int = 8
class CrossCourseConfig(BaseModel):
detect_title_overlaps: bool = True
detect_term_conflicts: bool = True
detect_order_conflicts: bool = True
merge_same_named_lessons: bool = True
class ReviewConfig(BaseModel):
default_reviewer: str = "Unknown Reviewer"
allow_provisional_concepts: bool = True
write_promoted_pack: bool = True
write_review_ledger: bool = True
class AppConfig(BaseModel):
document_adapters: DocumentAdaptersConfig = Field(default_factory=DocumentAdaptersConfig)
course_ingest: CourseIngestConfig = Field(default_factory=CourseIngestConfig)
cross_course: CrossCourseConfig = Field(default_factory=CrossCourseConfig)
review: ReviewConfig = Field(default_factory=ReviewConfig)
def load_config(path: str | Path) -> AppConfig:

View File

@ -4,69 +4,74 @@ import argparse
from pathlib import Path
from .config import load_config
from .document_adapters import adapt_document
from .topic_ingest import document_to_course, build_topic_bundle, merge_courses_into_topic_course, extract_concept_candidates
from .cross_course_conflicts import detect_title_overlaps, detect_term_conflicts, detect_order_conflicts, detect_thin_concepts
from .rule_policy import RuleContext, build_default_rules, run_rules
from .pack_emitter import build_draft_pack, write_draft_pack
from .review_loader import load_draft_pack
from .review_schema import ReviewSession, ReviewAction
from .review_actions import apply_action
from .review_export import export_review_state_json, export_promoted_pack, export_review_ui_data
from .ui_scaffold import write_review_ui
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Didactopus document-adapter and cross-course topic ingestion")
parser.add_argument("--inputs", nargs="+", required=True, help="Document inputs")
parser.add_argument("--title", required=True, help="Topic title")
parser.add_argument("--rights-note", default="REVIEW REQUIRED")
parser.add_argument("--output-dir", default="generated-topic-pack")
parser = argparse.ArgumentParser(description="Didactopus review workflow scaffold")
parser.add_argument("--draft-pack", required=True, help="Path to draft pack directory")
parser.add_argument("--output-dir", default="review-output")
parser.add_argument("--config", default="configs/config.example.yaml")
return parser
def main() -> None:
args = build_parser().parse_args()
config = load_config(args.config)
config = load_config(Path(args.config))
draft = load_draft_pack(args.draft_pack)
session = ReviewSession(reviewer=config.review.default_reviewer, draft_pack=draft)
docs = [adapt_document(path) for path in args.inputs]
courses = [document_to_course(doc, course_title=args.title) for doc in docs]
topic = build_topic_bundle(args.title, courses)
merged_course = merge_courses_into_topic_course(
topic_bundle=topic,
merge_same_named_lessons=config.cross_course.merge_same_named_lessons,
)
concepts = extract_concept_candidates(merged_course)
# Demo curation actions
if session.draft_pack.concepts:
first = session.draft_pack.concepts[0].concept_id
apply_action(session, session.reviewer, ReviewAction(
action_type="set_status",
target=first,
payload={"status": "trusted"},
rationale="Initial concept appears well grounded.",
))
apply_action(session, session.reviewer, ReviewAction(
action_type="note",
target=first,
payload={"note": "Reviewed in initial curation pass."},
rationale="Record reviewer note.",
))
context = RuleContext(course=merged_course, concepts=concepts)
rules = build_default_rules()
run_rules(context, rules)
if len(session.draft_pack.concepts) > 1:
second = session.draft_pack.concepts[1].concept_id
apply_action(session, session.reviewer, ReviewAction(
action_type="set_status",
target=second,
payload={"status": "provisional"},
rationale="Keep provisional pending further review.",
))
conflicts = []
if config.cross_course.detect_title_overlaps:
conflicts.extend(detect_title_overlaps(merged_course))
if config.cross_course.detect_term_conflicts:
conflicts.extend(detect_term_conflicts(merged_course))
if config.cross_course.detect_order_conflicts:
conflicts.extend(detect_order_conflicts(merged_course))
conflicts.extend(detect_thin_concepts(context.concepts))
if session.draft_pack.conflicts:
apply_action(session, session.reviewer, ReviewAction(
action_type="resolve_conflict",
target="",
payload={"conflict": session.draft_pack.conflicts[0]},
rationale="Resolved first conflict in demo workflow.",
))
draft = build_draft_pack(
course=merged_course,
concepts=context.concepts,
author=config.course_ingest.default_pack_author,
license_name=config.course_ingest.default_license,
review_flags=context.review_flags,
conflicts=conflicts,
)
write_draft_pack(draft, args.output_dir)
outdir = Path(args.output_dir)
outdir.mkdir(parents=True, exist_ok=True)
print("== Didactopus Cross-Course Topic Ingest ==")
print(f"Topic: {args.title}")
print(f"Documents: {len(docs)}")
print(f"Courses: {len(courses)}")
print(f"Merged modules: {len(merged_course.modules)}")
print(f"Concept candidates: {len(context.concepts)}")
print(f"Review flags: {len(context.review_flags)}")
print(f"Conflicts: {len(conflicts)}")
print(f"Output dir: {args.output_dir}")
export_review_state_json(session, outdir / "review_session.json")
export_review_ui_data(session, outdir)
write_review_ui(outdir)
if config.review.write_promoted_pack:
export_promoted_pack(session, outdir / "promoted_pack")
if __name__ == "__main__":
main()
print("== Didactopus Review Workflow ==")
print(f"Draft pack: {args.draft_pack}")
print(f"Reviewer: {session.reviewer}")
print(f"Concepts: {len(session.draft_pack.concepts)}")
print(f"Ledger entries: {len(session.ledger)}")
print(f"Remaining conflicts: {len(session.draft_pack.conflicts)}")
print(f"Output dir: {outdir}")

View File

@ -0,0 +1,50 @@
from __future__ import annotations
from .review_schema import ReviewAction, ReviewLedgerEntry, ReviewSession
def _find_concept(session: ReviewSession, concept_id: str):
for concept in session.draft_pack.concepts:
if concept.concept_id == concept_id:
return concept
return None
def apply_action(session: ReviewSession, reviewer: str, action: ReviewAction) -> None:
target = _find_concept(session, action.target)
if action.action_type == "set_status" and target is not None:
target.status = action.payload.get("status", target.status)
elif action.action_type == "edit_prerequisites" and target is not None:
target.prerequisites = list(action.payload.get("prerequisites", target.prerequisites))
elif action.action_type == "edit_title" and target is not None:
target.title = action.payload.get("title", target.title)
elif action.action_type == "edit_description" and target is not None:
target.description = action.payload.get("description", target.description)
elif action.action_type == "resolve_conflict":
text = action.payload.get("conflict", "")
if text in session.draft_pack.conflicts:
session.draft_pack.conflicts.remove(text)
elif action.action_type == "note" and target is not None:
note = action.payload.get("note", "")
if note:
target.notes.append(note)
elif action.action_type == "merge_concepts":
source = _find_concept(session, action.payload.get("source", ""))
dest = _find_concept(session, action.payload.get("destination", ""))
if source is not None and dest is not None and source is not dest:
for prereq in source.prerequisites:
if prereq not in dest.prerequisites:
dest.prerequisites.append(prereq)
for sig in source.mastery_signals:
if sig not in dest.mastery_signals:
dest.mastery_signals.append(sig)
for note in source.notes:
if note not in dest.notes:
dest.notes.append(note)
source.status = "rejected"
source.notes.append(f"Merged into {dest.concept_id}")
elif action.action_type == "split_concept" and target is not None:
target.notes.append("Split requested; manual follow-up required.")
session.ledger.append(ReviewLedgerEntry(reviewer=reviewer, action=action))

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
import json
import yaml
from .review_schema import ReviewSession
def export_review_state_json(session: ReviewSession, path: str | Path) -> None:
Path(path).write_text(session.model_dump_json(indent=2), encoding="utf-8")
def export_promoted_pack(session: ReviewSession, outdir: str | Path) -> None:
outdir = Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
promoted_pack = dict(session.draft_pack.pack)
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),
}
trusted_concepts = []
for concept in session.draft_pack.concepts:
if concept.status == "rejected":
continue
trusted_concepts.append({
"id": concept.concept_id,
"title": concept.title,
"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_pack, sort_keys=False), encoding="utf-8")
(outdir / "concepts.yaml").write_text(yaml.safe_dump({"concepts": trusted_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")
def export_review_ui_data(session: ReviewSession, outdir: str | Path) -> None:
outdir = Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
data = {
"reviewer": session.reviewer,
"pack": session.draft_pack.pack,
"concepts": [c.model_dump() for c in session.draft_pack.concepts],
"conflicts": session.draft_pack.conflicts,
"review_flags": session.draft_pack.review_flags,
"ledger": [entry.model_dump() for entry in session.ledger],
}
(outdir / "review_data.json").write_text(json.dumps(data, indent=2), encoding="utf-8")

View File

@ -0,0 +1,58 @@
from __future__ import annotations
from pathlib import Path
import json
import yaml
from .review_schema import DraftPackData, ConceptReviewEntry
def load_draft_pack(pack_dir: str | Path) -> DraftPackData:
pack_dir = Path(pack_dir)
concepts_yaml = yaml.safe_load((pack_dir / "concepts.yaml").read_text(encoding="utf-8")) or {}
concepts = []
for item in concepts_yaml.get("concepts", []):
concepts.append(
ConceptReviewEntry(
concept_id=item.get("id", ""),
title=item.get("title", ""),
description=item.get("description", ""),
prerequisites=list(item.get("prerequisites", [])),
mastery_signals=list(item.get("mastery_signals", [])),
)
)
conflicts_path = pack_dir / "conflict_report.md"
review_path = pack_dir / "review_report.md"
attribution_path = pack_dir / "license_attribution.json"
pack_path = pack_dir / "pack.yaml"
conflicts = []
if conflicts_path.exists():
conflicts = [
line[2:] for line in conflicts_path.read_text(encoding="utf-8").splitlines()
if line.startswith("- ")
]
review_flags = []
if review_path.exists():
review_flags = [
line[2:] for line in review_path.read_text(encoding="utf-8").splitlines()
if line.startswith("- ")
]
attribution = {}
if attribution_path.exists():
attribution = json.loads(attribution_path.read_text(encoding="utf-8"))
pack = {}
if pack_path.exists():
pack = yaml.safe_load(pack_path.read_text(encoding="utf-8")) or {}
return DraftPackData(
pack=pack,
concepts=concepts,
conflicts=conflicts,
review_flags=review_flags,
attribution=attribution,
)

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Literal
TrustStatus = Literal["trusted", "provisional", "rejected", "needs_review"]
ActionType = Literal[
"set_status",
"edit_prerequisites",
"edit_title",
"edit_description",
"merge_concepts",
"split_concept",
"resolve_conflict",
"note",
]
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 ReviewAction(BaseModel):
action_type: ActionType
target: str
payload: dict = Field(default_factory=dict)
rationale: str = ""
class ReviewLedgerEntry(BaseModel):
reviewer: str
action: ReviewAction
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 ReviewSession(BaseModel):
reviewer: str
draft_pack: DraftPackData
ledger: list[ReviewLedgerEntry] = Field(default_factory=list)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Didactopus Review UI</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; line-height: 1.4; }
.card { border: 1px solid #bbb; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.small { color: #555; font-size: 0.95rem; }
code { background: #f3f3f3; padding: 0.1rem 0.3rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>Didactopus Draft Pack Review</h1>
<p class="small">Static scaffold. Load <code>review_data.json</code> next to this file for future richer UI behavior.</p>
<div class="card">
<h2>Workflow</h2>
<ol>
<li>Inspect concept list and statuses</li>
<li>Review prerequisite edges</li>
<li>Resolve conflicts and flags</li>
<li>Record curation actions in ledger</li>
<li>Promote reviewed pack</li>
</ol>
</div>
<div class="card">
<h2>Expected local files</h2>
<ul>
<li><code>review_data.json</code></li>
<li><code>review_ledger.json</code></li>
<li><code>pack.yaml</code></li>
<li><code>concepts.yaml</code></li>
</ul>
</div>
<div class="card">
<h2>Next UI step</h2>
<p>Replace this scaffold with a SPA that edits the same JSON review-state model.</p>
</div>
</body>
</html>
'''
def write_review_ui(outdir: str | Path) -> None:
outdir = Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
(outdir / "index.html").write_text(HTML, encoding="utf-8")

View File

@ -0,0 +1,28 @@
from didactopus.review_schema import DraftPackData, ConceptReviewEntry, ReviewSession, ReviewAction
from didactopus.review_actions import apply_action
def test_apply_status_action() -> None:
session = ReviewSession(
reviewer="R",
draft_pack=DraftPackData(
concepts=[ConceptReviewEntry(concept_id="c1", title="C1")]
),
)
apply_action(session, "R", ReviewAction(action_type="set_status", target="c1", payload={"status": "trusted"}))
assert session.draft_pack.concepts[0].status == "trusted"
assert len(session.ledger) == 1
def test_merge_action() -> None:
session = ReviewSession(
reviewer="R",
draft_pack=DraftPackData(
concepts=[
ConceptReviewEntry(concept_id="a", title="A"),
ConceptReviewEntry(concept_id="b", title="B"),
]
),
)
apply_action(session, "R", ReviewAction(action_type="merge_concepts", target="", payload={"source": "a", "destination": "b"}))
assert session.draft_pack.concepts[0].status == "rejected"

View File

@ -0,0 +1,20 @@
from pathlib import Path
from didactopus.review_schema import DraftPackData, ConceptReviewEntry, ReviewSession
from didactopus.review_export import export_review_state_json, export_promoted_pack, export_review_ui_data
def test_exports(tmp_path: Path) -> None:
session = ReviewSession(
reviewer="R",
draft_pack=DraftPackData(
pack={"name": "test", "version": "0.1.0-draft"},
concepts=[ConceptReviewEntry(concept_id="c1", title="C1", status="trusted")],
attribution={"rights_note": "REVIEW REQUIRED"},
),
)
export_review_state_json(session, tmp_path / "review_session.json")
export_review_ui_data(session, tmp_path)
export_promoted_pack(session, tmp_path / "promoted")
assert (tmp_path / "review_session.json").exists()
assert (tmp_path / "review_data.json").exists()
assert (tmp_path / "promoted" / "pack.yaml").exists()

View File

@ -0,0 +1,15 @@
from pathlib import Path
from didactopus.review_loader import load_draft_pack
def test_load_draft_pack(tmp_path: Path) -> None:
(tmp_path / "pack.yaml").write_text("name: test\n", encoding="utf-8")
(tmp_path / "concepts.yaml").write_text(
"concepts:\n - id: c1\n title: Concept One\n description: Desc\n prerequisites: []\n mastery_signals: []\n",
encoding="utf-8",
)
(tmp_path / "conflict_report.md").write_text("# Conflict Report\n\n- One conflict\n", encoding="utf-8")
data = load_draft_pack(tmp_path)
assert data.pack["name"] == "test"
assert len(data.concepts) == 1
assert len(data.conflicts) == 1

View File

@ -0,0 +1,7 @@
from pathlib import Path
from didactopus.ui_scaffold import write_review_ui
def test_write_ui(tmp_path: Path) -> None:
write_review_ui(tmp_path)
assert (tmp_path / "index.html").exists()