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 (
-
+
);
}
-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; }
}