diff --git a/README.md b/README.md index e378a84..7e24f8a 100644 --- a/README.md +++ b/README.md @@ -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/ ``` - - - - diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 320a6d0..5544752 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -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 diff --git a/docs/faq.md b/docs/faq.md index 805941f..607cd01 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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. diff --git a/docs/review-workflow.md b/docs/review-workflow.md new file mode 100644 index 0000000..9f85b75 --- /dev/null +++ b/docs/review-workflow.md @@ -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 diff --git a/examples/draft_pack/concepts.yaml b/examples/draft_pack/concepts.yaml new file mode 100644 index 0000000..d94fa8d --- /dev/null +++ b/examples/draft_pack/concepts.yaml @@ -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: {} diff --git a/examples/draft_pack/conflict_report.md b/examples/draft_pack/conflict_report.md new file mode 100644 index 0000000..370e264 --- /dev/null +++ b/examples/draft_pack/conflict_report.md @@ -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. diff --git a/examples/draft_pack/license_attribution.json b/examples/draft_pack/license_attribution.json new file mode 100644 index 0000000..81d9e31 --- /dev/null +++ b/examples/draft_pack/license_attribution.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/examples/draft_pack/pack.yaml b/examples/draft_pack/pack.yaml new file mode 100644 index 0000000..5db7c2d --- /dev/null +++ b/examples/draft_pack/pack.yaml @@ -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: [] diff --git a/examples/draft_pack/review_report.md b/examples/draft_pack/review_report.md new file mode 100644 index 0000000..7a6ce20 --- /dev/null +++ b/examples/draft_pack/review_report.md @@ -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. diff --git a/examples/review_output/index.html b/examples/review_output/index.html new file mode 100644 index 0000000..774e02b --- /dev/null +++ b/examples/review_output/index.html @@ -0,0 +1 @@ +

Didactopus Review UI

Static scaffold.

\ No newline at end of file diff --git a/examples/review_output/promoted_pack/concepts.yaml b/examples/review_output/promoted_pack/concepts.yaml new file mode 100644 index 0000000..f4d3abe --- /dev/null +++ b/examples/review_output/promoted_pack/concepts.yaml @@ -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: {} diff --git a/examples/review_output/promoted_pack/pack.yaml b/examples/review_output/promoted_pack/pack.yaml new file mode 100644 index 0000000..ae291c3 --- /dev/null +++ b/examples/review_output/promoted_pack/pack.yaml @@ -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 diff --git a/examples/review_output/promoted_pack/review_ledger.json b/examples/review_output/promoted_pack/review_ledger.json new file mode 100644 index 0000000..8f1fc7b --- /dev/null +++ b/examples/review_output/promoted_pack/review_ledger.json @@ -0,0 +1,4 @@ +{ + "reviewer": "Wesley R. Elsberry", + "entries": 2 +} \ No newline at end of file diff --git a/examples/review_output/review_data.json b/examples/review_output/review_data.json new file mode 100644 index 0000000..1c81f9d --- /dev/null +++ b/examples/review_output/review_data.json @@ -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": [] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8716c9e..d94ce90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 6dfcf72..01f66f7 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -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: diff --git a/src/didactopus/main.py b/src/didactopus/main.py index 95a1076..cd0d5a9 100644 --- a/src/didactopus/main.py +++ b/src/didactopus/main.py @@ -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}") diff --git a/src/didactopus/review_actions.py b/src/didactopus/review_actions.py new file mode 100644 index 0000000..692f16d --- /dev/null +++ b/src/didactopus/review_actions.py @@ -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)) diff --git a/src/didactopus/review_export.py b/src/didactopus/review_export.py new file mode 100644 index 0000000..873ca97 --- /dev/null +++ b/src/didactopus/review_export.py @@ -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") diff --git a/src/didactopus/review_loader.py b/src/didactopus/review_loader.py new file mode 100644 index 0000000..8ca90da --- /dev/null +++ b/src/didactopus/review_loader.py @@ -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, + ) diff --git a/src/didactopus/review_schema.py b/src/didactopus/review_schema.py new file mode 100644 index 0000000..ff82ccd --- /dev/null +++ b/src/didactopus/review_schema.py @@ -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) diff --git a/src/didactopus/ui_scaffold.py b/src/didactopus/ui_scaffold.py new file mode 100644 index 0000000..7e58105 --- /dev/null +++ b/src/didactopus/ui_scaffold.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + + +HTML = ''' + + + + +Didactopus Review UI + + + +

Didactopus Draft Pack Review

+

Static scaffold. Load review_data.json next to this file for future richer UI behavior.

+ +
+

Workflow

+
    +
  1. Inspect concept list and statuses
  2. +
  3. Review prerequisite edges
  4. +
  5. Resolve conflicts and flags
  6. +
  7. Record curation actions in ledger
  8. +
  9. Promote reviewed pack
  10. +
+
+ +
+

Expected local files

+ +
+ +
+

Next UI step

+

Replace this scaffold with a SPA that edits the same JSON review-state model.

+
+ + +''' + + +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") diff --git a/tests/test_review_actions.py b/tests/test_review_actions.py new file mode 100644 index 0000000..6548668 --- /dev/null +++ b/tests/test_review_actions.py @@ -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" diff --git a/tests/test_review_export.py b/tests/test_review_export.py new file mode 100644 index 0000000..584c548 --- /dev/null +++ b/tests/test_review_export.py @@ -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() diff --git a/tests/test_review_loader.py b/tests/test_review_loader.py new file mode 100644 index 0000000..4235d29 --- /dev/null +++ b/tests/test_review_loader.py @@ -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 diff --git a/tests/test_ui_scaffold.py b/tests/test_ui_scaffold.py new file mode 100644 index 0000000..b0b2e29 --- /dev/null +++ b/tests/test_ui_scaffold.py @@ -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()