From 420cdf096456e8beda5401a0abb4aca2a7056ea4 Mon Sep 17 00:00:00 2001 From: welsberr Date: Sat, 14 Mar 2026 13:29:56 -0400 Subject: [PATCH] Apply ZIP update: 280-didactopus-review-workbench-and-synthesis-scaffold.zip [2026-03-14T13:21:20] --- src/didactopus/api.py | 30 ++--- src/didactopus/models.py | 16 +++ src/didactopus/orm.py | 57 +++------- src/didactopus/repository.py | 209 ++++++++++------------------------- src/didactopus/synthesis.py | 4 +- webui/index.html | 2 +- webui/package.json | 2 +- webui/src/App.jsx | 160 ++++++++++++++------------- webui/src/api.js | 27 ++--- webui/src/styles.css | 10 +- 10 files changed, 211 insertions(+), 306 deletions(-) diff --git a/src/didactopus/api.py b/src/didactopus/api.py index af50ea7..1798efa 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -4,23 +4,22 @@ from fastapi.middleware.cors import CORSMiddleware import uvicorn from .db import Base, engine from .models import ( - LoginRequest, TokenPair, KnowledgeCandidateCreate, + LoginRequest, TokenPair, KnowledgeCandidateCreate, KnowledgeCandidateUpdate, ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest, CreateLearnerRequest ) from .repository import ( - authenticate_user, get_user_by_id, create_learner, - create_candidate, list_candidates, get_candidate, + authenticate_user, get_user_by_id, create_learner, learner_owned_by_user, + create_candidate, list_candidates, get_candidate, update_candidate, create_review, list_reviews, create_promotion, list_promotions, - list_synthesis_candidates, get_synthesis_candidate, - list_pack_patches, list_curriculum_drafts, list_skill_bundles + list_synthesis_candidates, get_synthesis_candidate ) from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .synthesis import generate_synthesis_candidates Base.metadata.create_all(bind=engine) -app = FastAPI(title="Didactopus Promotion Target Objects API") +app = FastAPI(title="Didactopus Review Workbench API") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) _refresh_tokens = {} @@ -75,6 +74,13 @@ def api_get_candidate(candidate_id: int, reviewer = Depends(require_reviewer)): raise HTTPException(status_code=404, detail="Candidate not found") return row +@app.post("/api/knowledge-candidates/{candidate_id}/update") +def api_update_candidate(candidate_id: int, payload: KnowledgeCandidateUpdate, reviewer = Depends(require_reviewer)): + row = update_candidate(candidate_id, triage_lane=payload.triage_lane, current_status=payload.current_status) + if row is None: + raise HTTPException(status_code=404, detail="Candidate not found") + return {"candidate_id": row.id, "triage_lane": row.triage_lane, "current_status": row.current_status} + @app.post("/api/knowledge-candidates/{candidate_id}/reviews") def api_create_review(candidate_id: int, payload: ReviewCreate, reviewer = Depends(require_reviewer)): if get_candidate(candidate_id) is None: @@ -97,18 +103,6 @@ def api_promote_candidate(candidate_id: int, payload: PromoteRequest, reviewer = def api_list_promotions(reviewer = Depends(require_reviewer)): return list_promotions() -@app.get("/api/pack-patches") -def api_list_pack_patches(reviewer = Depends(require_reviewer)): - return list_pack_patches() - -@app.get("/api/curriculum-drafts") -def api_list_curriculum_drafts(reviewer = Depends(require_reviewer)): - return list_curriculum_drafts() - -@app.get("/api/skill-bundles") -def api_list_skill_bundles(reviewer = Depends(require_reviewer)): - return list_skill_bundles() - @app.post("/api/synthesis/run") def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_reviewer)): created = generate_synthesis_candidates(payload.source_pack_id, payload.target_pack_id, payload.limit) diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 086c82f..16f12be 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -27,6 +27,10 @@ class KnowledgeCandidateCreate(BaseModel): synthesis_score: float = 0.0 triage_lane: str = "archive" +class KnowledgeCandidateUpdate(BaseModel): + triage_lane: str | None = None + current_status: str | None = None + class ReviewCreate(BaseModel): review_kind: str = "human_review" verdict: str @@ -49,3 +53,15 @@ class SynthesisPromoteRequest(BaseModel): class CreateLearnerRequest(BaseModel): learner_id: str display_name: str = "" + +class MasteryRecord(BaseModel): + concept_id: str + dimension: str + score: float = 0.0 + confidence: float = 0.0 + evidence_count: int = 0 + last_updated: str = "" + +class LearnerState(BaseModel): + learner_id: str + records: list[MasteryRecord] = Field(default_factory=list) diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 3fff9bd..668611f 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -21,6 +21,23 @@ class PackORM(Base): data_json: Mapped[str] = mapped_column(Text) is_published: Mapped[bool] = mapped_column(Boolean, default=False) +class LearnerORM(Base): + __tablename__ = "learners" + id: Mapped[str] = mapped_column(String(100), primary_key=True) + owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + display_name: Mapped[str] = mapped_column(String(255), default="") + +class MasteryRecordORM(Base): + __tablename__ = "mastery_records" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True) + concept_id: Mapped[str] = mapped_column(String(100), index=True) + dimension: Mapped[str] = mapped_column(String(100), default="mastery") + score: Mapped[float] = mapped_column(Float, default=0.0) + confidence: Mapped[float] = mapped_column(Float, default=0.0) + evidence_count: Mapped[int] = mapped_column(Integer, default=0) + last_updated: Mapped[str] = mapped_column(String(100), default="") + class KnowledgeCandidateORM(Base): __tablename__ = "knowledge_candidates" id: Mapped[int] = mapped_column(Integer, primary_key=True) @@ -78,43 +95,3 @@ class SynthesisCandidateORM(Base): evidence_json: Mapped[str] = mapped_column(Text, default="{}") current_status: Mapped[str] = mapped_column(String(50), default="proposed") created_at: Mapped[str] = mapped_column(String(100), default="") - -class PackPatchProposalORM(Base): - __tablename__ = "pack_patch_proposals" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) - pack_id: Mapped[str] = mapped_column(String(100), index=True) - patch_type: Mapped[str] = mapped_column(String(100), default="content_revision") - title: Mapped[str] = mapped_column(String(255)) - proposed_change_json: Mapped[str] = mapped_column(Text, default="{}") - evidence_summary: Mapped[str] = mapped_column(Text, default="") - reviewer_notes: Mapped[str] = mapped_column(Text, default="") - status: Mapped[str] = mapped_column(String(50), default="proposed") - created_at: Mapped[str] = mapped_column(String(100), default="") - -class CurriculumDraftORM(Base): - __tablename__ = "curriculum_drafts" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) - topic_focus: Mapped[str] = mapped_column(String(255), default="") - product_type: Mapped[str] = mapped_column(String(100), default="lesson_outline") - audience: Mapped[str] = mapped_column(String(100), default="general") - source_concepts_json: Mapped[str] = mapped_column(Text, default="[]") - content_markdown: Mapped[str] = mapped_column(Text, default="") - editorial_notes: Mapped[str] = mapped_column(Text, default="") - status: Mapped[str] = mapped_column(String(50), default="draft") - created_at: Mapped[str] = mapped_column(String(100), default="") - -class SkillBundleORM(Base): - __tablename__ = "skill_bundles" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) - skill_name: Mapped[str] = mapped_column(String(255)) - domain: Mapped[str] = mapped_column(String(100), default="") - prerequisites_json: Mapped[str] = mapped_column(Text, default="[]") - expected_inputs_json: Mapped[str] = mapped_column(Text, default="[]") - failure_modes_json: Mapped[str] = mapped_column(Text, default="[]") - validation_checks_json: Mapped[str] = mapped_column(Text, default="[]") - canonical_examples_json: Mapped[str] = mapped_column(Text, default="[]") - status: Mapped[str] = mapped_column(String(50), default="draft") - created_at: Mapped[str] = mapped_column(String(100), default="") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index e4ca877..c1f454b 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -4,9 +4,8 @@ from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal from .orm import ( - UserORM, PackORM, LearnerORM, - KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM, - PackPatchProposalORM, CurriculumDraftORM, SkillBundleORM + UserORM, PackORM, LearnerORM, MasteryRecordORM, + KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM ) from .auth import verify_password @@ -31,12 +30,33 @@ def list_packs(): with SessionLocal() as db: return db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() +def get_pack(pack_id: str): + with SessionLocal() as db: + return db.get(PackORM, pack_id) + def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): with SessionLocal() as db: if db.get(LearnerORM, learner_id) is None: db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) db.commit() +def learner_owned_by_user(user_id: int, learner_id: str) -> bool: + with SessionLocal() as db: + learner = db.get(LearnerORM, learner_id) + return learner is not None and learner.owner_user_id == user_id + +def list_mastery_records(learner_id: str): + with SessionLocal() as db: + rows = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() + return [{ + "concept_id": r.concept_id, + "dimension": r.dimension, + "score": r.score, + "confidence": r.confidence, + "evidence_count": r.evidence_count, + "last_updated": r.last_updated, + } for r in rows] + def create_candidate(payload): with SessionLocal() as db: row = KnowledgeCandidateORM( @@ -64,26 +84,24 @@ def create_candidate(payload): def list_candidates(): with SessionLocal() as db: rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all() - out = [] - for r in rows: - out.append({ - "candidate_id": r.id, - "source_type": r.source_type, - "learner_id": r.learner_id, - "pack_id": r.pack_id, - "candidate_kind": r.candidate_kind, - "title": r.title, - "summary": r.summary, - "structured_payload": json.loads(r.structured_payload_json or "{}"), - "evidence_summary": r.evidence_summary, - "confidence_hint": r.confidence_hint, - "novelty_score": r.novelty_score, - "synthesis_score": r.synthesis_score, - "triage_lane": r.triage_lane, - "current_status": r.current_status, - "created_at": r.created_at, - }) - return out + return [{ + "candidate_id": r.id, + "source_type": r.source_type, + "source_artifact_id": r.source_artifact_id, + "learner_id": r.learner_id, + "pack_id": r.pack_id, + "candidate_kind": r.candidate_kind, + "title": r.title, + "summary": r.summary, + "structured_payload": json.loads(r.structured_payload_json or "{}"), + "evidence_summary": r.evidence_summary, + "confidence_hint": r.confidence_hint, + "novelty_score": r.novelty_score, + "synthesis_score": r.synthesis_score, + "triage_lane": r.triage_lane, + "current_status": r.current_status, + "created_at": r.created_at, + } for r in rows] def get_candidate(candidate_id: int): with SessionLocal() as db: @@ -93,6 +111,7 @@ def get_candidate(candidate_id: int): return { "candidate_id": r.id, "source_type": r.source_type, + "source_artifact_id": r.source_artifact_id, "learner_id": r.learner_id, "pack_id": r.pack_id, "candidate_kind": r.candidate_kind, @@ -108,6 +127,19 @@ def get_candidate(candidate_id: int): "created_at": r.created_at, } +def update_candidate(candidate_id: int, triage_lane=None, current_status=None): + with SessionLocal() as db: + row = db.get(KnowledgeCandidateORM, candidate_id) + if row is None: + return None + if triage_lane is not None: + row.triage_lane = triage_lane + if current_status is not None: + row.current_status = current_status + db.commit() + db.refresh(row) + return row + def create_review(candidate_id: int, reviewer_id: int, payload): with SessionLocal() as db: row = ReviewRecordORM( @@ -138,93 +170,21 @@ def list_reviews(candidate_id: int): "created_at": r.created_at, } for r in rows] -def create_pack_patch(candidate, reviewer_notes: str = ""): - with SessionLocal() as db: - row = PackPatchProposalORM( - candidate_id=candidate["candidate_id"], - pack_id=candidate["pack_id"], - patch_type=candidate["candidate_kind"], - title=candidate["title"], - proposed_change_json=json.dumps(candidate["structured_payload"]), - evidence_summary=candidate["evidence_summary"], - reviewer_notes=reviewer_notes, - status="proposed", - created_at=now_iso(), - ) - db.add(row) - db.commit() - db.refresh(row) - return f"patch:{row.id}" - -def create_curriculum_draft(candidate, reviewer_notes: str = ""): - with SessionLocal() as db: - payload = candidate["structured_payload"] - source_concepts = payload.get("source_concepts", [payload.get("affected_concept")] if payload.get("affected_concept") else []) - content = f"# {candidate['title']}\n\n{candidate['summary']}\n\n## Evidence\n{candidate['evidence_summary']}\n" - row = CurriculumDraftORM( - candidate_id=candidate["candidate_id"], - topic_focus=candidate["title"], - product_type="lesson_outline", - audience="general", - source_concepts_json=json.dumps(source_concepts), - content_markdown=content, - editorial_notes=reviewer_notes, - status="draft", - created_at=now_iso(), - ) - db.add(row) - db.commit() - db.refresh(row) - return f"curriculum:{row.id}" - -def create_skill_bundle(candidate, reviewer_notes: str = ""): - with SessionLocal() as db: - payload = candidate["structured_payload"] - row = SkillBundleORM( - candidate_id=candidate["candidate_id"], - skill_name=candidate["title"], - domain=candidate["pack_id"], - prerequisites_json=json.dumps(payload.get("prerequisites", [])), - expected_inputs_json=json.dumps(payload.get("expected_inputs", ["text"])), - failure_modes_json=json.dumps(payload.get("failure_modes", ["misapplied concept"])), - validation_checks_json=json.dumps(payload.get("validation_checks", ["can explain concept clearly"])), - canonical_examples_json=json.dumps(payload.get("canonical_examples", [candidate["summary"]])), - status="draft", - created_at=now_iso(), - ) - db.add(row) - db.commit() - db.refresh(row) - return f"skill:{row.id}" - def create_promotion(candidate_id: int, promoted_by: int, payload): - candidate = get_candidate(candidate_id) - if candidate is None: - return None - target_object_id = payload.target_object_id - if not target_object_id: - if payload.promotion_target == "pack_improvement": - target_object_id = create_pack_patch(candidate) - elif payload.promotion_target == "curriculum_draft": - target_object_id = create_curriculum_draft(candidate) - elif payload.promotion_target == "reusable_skill_bundle": - target_object_id = create_skill_bundle(candidate) - elif payload.promotion_target == "archive": - target_object_id = "archive:auto" with SessionLocal() as db: row = PromotionRecordORM( candidate_id=candidate_id, promotion_target=payload.promotion_target, - target_object_id=target_object_id, + target_object_id=payload.target_object_id, promotion_status=payload.promotion_status, promoted_by=promoted_by, created_at=now_iso(), ) db.add(row) - cand = db.get(KnowledgeCandidateORM, candidate_id) - if cand: - cand.current_status = "promoted" if payload.promotion_target != "archive" else "archived" - cand.triage_lane = payload.promotion_target + candidate = db.get(KnowledgeCandidateORM, candidate_id) + if candidate: + candidate.current_status = "promoted" + candidate.triage_lane = payload.promotion_target db.commit() db.refresh(row) return row.id @@ -242,55 +202,6 @@ def list_promotions(): "created_at": r.created_at, } for r in rows] -def list_pack_patches(): - with SessionLocal() as db: - rows = db.execute(select(PackPatchProposalORM).order_by(PackPatchProposalORM.id.desc())).scalars().all() - return [{ - "patch_id": r.id, - "candidate_id": r.candidate_id, - "pack_id": r.pack_id, - "patch_type": r.patch_type, - "title": r.title, - "proposed_change": json.loads(r.proposed_change_json or "{}"), - "evidence_summary": r.evidence_summary, - "reviewer_notes": r.reviewer_notes, - "status": r.status, - "created_at": r.created_at, - } for r in rows] - -def list_curriculum_drafts(): - with SessionLocal() as db: - rows = db.execute(select(CurriculumDraftORM).order_by(CurriculumDraftORM.id.desc())).scalars().all() - return [{ - "draft_id": r.id, - "candidate_id": r.candidate_id, - "topic_focus": r.topic_focus, - "product_type": r.product_type, - "audience": r.audience, - "source_concepts": json.loads(r.source_concepts_json or "[]"), - "content_markdown": r.content_markdown, - "editorial_notes": r.editorial_notes, - "status": r.status, - "created_at": r.created_at, - } for r in rows] - -def list_skill_bundles(): - with SessionLocal() as db: - rows = db.execute(select(SkillBundleORM).order_by(SkillBundleORM.id.desc())).scalars().all() - return [{ - "skill_bundle_id": r.id, - "candidate_id": r.candidate_id, - "skill_name": r.skill_name, - "domain": r.domain, - "prerequisites": json.loads(r.prerequisites_json or "[]"), - "expected_inputs": json.loads(r.expected_inputs_json or "[]"), - "failure_modes": json.loads(r.failure_modes_json or "[]"), - "validation_checks": json.loads(r.validation_checks_json or "[]"), - "canonical_examples": json.loads(r.canonical_examples_json or "[]"), - "status": r.status, - "created_at": r.created_at, - } for r in rows] - def create_synthesis_candidate( source_concept_id: str, target_concept_id: str, diff --git a/src/didactopus/synthesis.py b/src/didactopus/synthesis.py index 2e12151..a980f75 100644 --- a/src/didactopus/synthesis.py +++ b/src/didactopus/synthesis.py @@ -32,6 +32,7 @@ def generate_synthesis_candidates(source_pack_id: str | None = None, target_pack by_id = {p.id: p for p in packs} source_packs = [by_id[source_pack_id]] if source_pack_id and source_pack_id in by_id else packs target_packs = [by_id[target_pack_id]] if target_pack_id and target_pack_id in by_id else packs + created = [] seen = set() for sp in source_packs: @@ -48,6 +49,7 @@ def generate_synthesis_candidates(source_pack_id: str | None = None, target_pack total = 0.35 * sem + 0.25 * struct + 0.20 * traj + 0.10 * review_prior + 0.10 * novelty if total < 0.45: continue + explanation = f"Possible cross-pack overlap between '{ca.get('title')}' and '{cb.get('title')}'." sid = create_synthesis_candidate( source_concept_id=ca.get("id", ""), target_concept_id=cb.get("id", ""), @@ -58,7 +60,7 @@ def generate_synthesis_candidates(source_pack_id: str | None = None, target_pack score_structural=struct, score_trajectory=traj, score_review_history=review_prior, - explanation=f"Possible cross-pack overlap between '{ca.get('title')}' and '{cb.get('title')}'.", + explanation=explanation, evidence={"novelty": novelty, "source_title": ca.get("title"), "target_title": cb.get("title")}, ) seen.add((ca.get("id"), cb.get("id"))) diff --git a/webui/index.html b/webui/index.html index 0bbd41c..0bdfcfa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Promotion Target Objects + Didactopus Review Workbench
diff --git a/webui/package.json b/webui/package.json index 92a1964..c562f41 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-promotion-target-objects-ui", + "name": "didactopus-review-workbench-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index ff3038f..2ac4da7 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,111 +1,130 @@ import React, { useEffect, useState } from "react"; -import { login, listCandidates, createCandidate, promoteCandidate, runSynthesis, listSynthesisCandidates, promoteSynthesis, listPackPatches, listCurriculumDrafts, listSkillBundles } from "./api"; +import { login, listCandidates, createCandidate, createReview, promoteCandidate, runSynthesis, listSynthesisCandidates, promoteSynthesis } from "./api"; function LoginView({ onAuth }) { const [username, setUsername] = useState("reviewer"); const [password, setPassword] = useState("demo-pass"); const [error, setError] = useState(""); async function doLogin() { - try { onAuth(await login(username, password)); } - catch { setError("Login failed"); } + try { + const result = await login(username, password); + onAuth(result); + } catch { + setError("Login failed"); + } } return ( -
-

Didactopus promotion targets

- - - - {error ?
{error}
: null} -
+
+
+

Didactopus review workbench

+ + + + {error ?
{error}
: null} +
+
); } -function CandidateCard({ candidate, onPromote }) { +function CandidateCard({ candidate, onReview, onPromote }) { return (

{candidate.title}

-
{candidate.candidate_kind} · {candidate.triage_lane}
+
{candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status}

{candidate.summary}

+
confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)}
- - - + + +
); } +function SynthesisCard({ item, onPromote }) { + return ( +
+

{item.source_concept_id} ↔ {item.target_concept_id}

+
{item.source_pack_id} → {item.target_pack_id}
+

{item.explanation}

+
total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)}
+ +
+ ); +} + export default function App() { const [auth, setAuth] = useState(null); const [candidates, setCandidates] = useState([]); const [synthesis, setSynthesis] = useState([]); - const [patches, setPatches] = useState([]); - const [curriculum, setCurriculum] = useState([]); - const [skills, setSkills] = useState([]); const [message, setMessage] = useState(""); async function reload(token = auth?.access_token) { if (!token) return; - const [c, s, p, d, k] = await Promise.all([ - listCandidates(token), - listSynthesisCandidates(token), - listPackPatches(token), - listCurriculumDrafts(token), - listSkillBundles(token), - ]); - setCandidates(c); - setSynthesis(s); - setPatches(p); - setCurriculum(d); - setSkills(k); + setCandidates(await listCandidates(token)); + setSynthesis(await listSynthesisCandidates(token)); } - useEffect(() => { if (auth?.access_token) reload(auth.access_token); }, [auth]); + useEffect(() => { + if (auth?.access_token) { + reload(auth.access_token); + } + }, [auth]); async function seedCandidate() { - await createCandidate(auth.access_token, { + const payload = { source_type: "learner_export", + source_artifact_id: null, learner_id: "wesley-learner", pack_id: "biology-pack", candidate_kind: "hidden_prerequisite", - title: "Probability intuition before drift", - summary: "Learner evidence suggests drift is easier after explicit random-process intuition.", - structured_payload: { - affected_concept: "drift", - prerequisites: ["variation", "random_walk"], - source_concepts: ["drift", "variation"], - expected_inputs: ["short explanation", "worked example"], - failure_modes: ["treating drift as directional"], - validation_checks: ["explains stochastic change"], - canonical_examples: ["coin flip population drift example"] - }, - evidence_summary: "Repeated learner confusion with stochastic interpretation.", - confidence_hint: 0.78, - novelty_score: 0.69, - synthesis_score: 0.55, + title: "Possible hidden prerequisite for drift", + summary: "Learner evidence suggests probability intuition should be explicit before drift.", + structured_payload: { affected_concept: "drift", suggested_prereq: "variation" }, + evidence_summary: "Repeated confusion on stochastic interpretation.", + confidence_hint: 0.73, + novelty_score: 0.66, + synthesis_score: 0.42, triage_lane: "pack_improvement" - }); + }; + await createCandidate(auth.access_token, payload); await reload(); setMessage("Seed candidate created."); } - async function doPromote(candidateId, target) { - await promoteCandidate(auth.access_token, candidateId, { promotion_target: target, target_object_id: "", promotion_status: "approved" }); + async function handleReview(candidateId, verdict) { + await createReview(auth.access_token, candidateId, { + review_kind: "human_review", + verdict, + rationale: "Accepted in reviewer workbench demo.", + requested_changes: "" + }); + await reload(); + setMessage(`Review added to candidate ${candidateId}.`); + } + + async function handlePromote(candidateId, target) { + await promoteCandidate(auth.access_token, candidateId, { + promotion_target: target, + target_object_id: "", + promotion_status: "approved" + }); await reload(); setMessage(`Candidate ${candidateId} promoted to ${target}.`); } - async function doSynthesis() { - await runSynthesis(auth.access_token, { source_pack_id: "biology-pack", target_pack_id: "math-pack", limit: 10 }); + async function handleRunSynthesis() { + await runSynthesis(auth.access_token, { source_pack_id: "biology-pack", target_pack_id: "math-pack", limit: 12 }); await reload(); setMessage("Synthesis run completed."); } - async function doPromoteSynthesis(synthesisId) { + async function handlePromoteSynthesis(synthesisId) { await promoteSynthesis(auth.access_token, synthesisId, { promotion_target: "pack_improvement" }); await reload(); - setMessage(`Synthesis ${synthesisId} promoted.`); + setMessage(`Synthesis candidate ${synthesisId} promoted into workflow.`); } if (!auth) return ; @@ -114,43 +133,32 @@ export default function App() {
-

Promotion target objects

-

Materialize promotions into patch proposals, curriculum drafts, and reusable skill bundles.

+

Review workbench + synthesis engine

+

Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles, or archive, while surfacing cross-pack synthesis proposals.

{message}
- +
-
+
-

Candidates

+

Knowledge candidates

- {candidates.map(c => )} -
-
-
-

Synthesis

-
- {synthesis.map(s => ( -
-

{s.source_concept_id} ↔ {s.target_concept_id}

-
{s.source_pack_id} → {s.target_pack_id}
-

{s.explanation}

- -
+ {candidates.map((c) => ( + ))}
-

Materialized outputs

+

Synthesis candidates

-

Pack patches

{JSON.stringify(patches, null, 2)}
-

Curriculum drafts

{JSON.stringify(curriculum, null, 2)}
-

Skill bundles

{JSON.stringify(skills, null, 2)}
+ {synthesis.map((s) => ( + + ))}
diff --git a/webui/src/api.js b/webui/src/api.js index a9234d8..4d35df8 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -11,48 +11,45 @@ export async function login(username, password) { if (!res.ok) throw new Error("login failed"); return await res.json(); } + export async function listCandidates(token) { const res = await fetch(`${API}/knowledge-candidates`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listCandidates failed"); return await res.json(); } + export async function createCandidate(token, payload) { const res = await fetch(`${API}/knowledge-candidates`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createCandidate failed"); return await res.json(); } + +export async function createReview(token, candidateId, payload) { + const res = await fetch(`${API}/knowledge-candidates/${candidateId}/reviews`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); + if (!res.ok) throw new Error("createReview failed"); + return await res.json(); +} + export async function promoteCandidate(token, candidateId, payload) { const res = await fetch(`${API}/knowledge-candidates/${candidateId}/promote`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("promoteCandidate failed"); return await res.json(); } + export async function runSynthesis(token, payload) { const res = await fetch(`${API}/synthesis/run`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("runSynthesis failed"); return await res.json(); } + export async function listSynthesisCandidates(token) { const res = await fetch(`${API}/synthesis/candidates`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listSynthesisCandidates failed"); return await res.json(); } + export async function promoteSynthesis(token, synthesisId, payload) { const res = await fetch(`${API}/synthesis/candidates/${synthesisId}/promote`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("promoteSynthesis failed"); return await res.json(); } -export async function listPackPatches(token) { - const res = await fetch(`${API}/pack-patches`, { headers: authHeaders(token, false) }); - if (!res.ok) throw new Error("listPackPatches failed"); - return await res.json(); -} -export async function listCurriculumDrafts(token) { - const res = await fetch(`${API}/curriculum-drafts`, { headers: authHeaders(token, false) }); - if (!res.ok) throw new Error("listCurriculumDrafts failed"); - return await res.json(); -} -export async function listSkillBundles(token) { - const res = await fetch(`${API}/skill-bundles`, { headers: authHeaders(token, false) }); - if (!res.ok) throw new Error("listSkillBundles failed"); - return await res.json(); -} diff --git a/webui/src/styles.css b/webui/src/styles.css index 6b186a6..dea47f1 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -3,11 +3,11 @@ } * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font-family:Arial, Helvetica, sans-serif; } -.page { max-width:1600px; margin:0 auto; padding:24px; } +.page { max-width:1500px; margin:0 auto; padding:24px; } .narrow { max-width:520px; } .hero, .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .hero { display:flex; justify-content:space-between; gap:16px; margin-bottom:18px; } -.grid3 { display:grid; grid-template-columns:1.1fr 1fr 1.2fr; gap:18px; } +.grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; } .stack { display:grid; gap:14px; } .card.small h3 { margin-top:0; } label { display:block; font-weight:600; margin-bottom:10px; } @@ -17,9 +17,9 @@ button.primary { background:var(--accent); color:white; border-color:var(--accen .actions { display:flex; flex-wrap:wrap; gap:8px; } .toolbar { display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap; } .muted { color:var(--muted); } -pre { white-space:pre-wrap; word-break:break-word; font-size:12px; margin:0; } +.tiny { font-size:12px; color:var(--muted); } .error { color:#b42318; margin-top:10px; } -@media (max-width: 1250px) { - .grid3 { grid-template-columns:1fr; } +@media (max-width: 1100px) { + .grid { grid-template-columns:1fr; } .hero { flex-direction:column; } }