Added review UI
This commit is contained in:
parent
0656f7bbe8
commit
1d0de94025
43
README.md
43
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/
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
docs/faq.md
30
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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: {}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
<!DOCTYPE html><html><body><h1>Didactopus Review UI</h1><p>Static scaffold.</p></body></html>
|
||||
|
|
@ -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: {}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"reviewer": "Wesley R. Elsberry",
|
||||
"entries": 2
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue