Apply ZIP update: 280-didactopus-review-workbench-and-synthesis-scaffold.zip [2026-03-14T13:21:20]
This commit is contained in:
parent
255bf19e50
commit
420cdf0964
|
|
@ -4,23 +4,22 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from .db import Base, engine
|
from .db import Base, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
LoginRequest, TokenPair, KnowledgeCandidateCreate,
|
LoginRequest, TokenPair, KnowledgeCandidateCreate, KnowledgeCandidateUpdate,
|
||||||
ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest,
|
ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest,
|
||||||
CreateLearnerRequest
|
CreateLearnerRequest
|
||||||
)
|
)
|
||||||
from .repository import (
|
from .repository import (
|
||||||
authenticate_user, get_user_by_id, create_learner,
|
authenticate_user, get_user_by_id, create_learner, learner_owned_by_user,
|
||||||
create_candidate, list_candidates, get_candidate,
|
create_candidate, list_candidates, get_candidate, update_candidate,
|
||||||
create_review, list_reviews, create_promotion, list_promotions,
|
create_review, list_reviews, create_promotion, list_promotions,
|
||||||
list_synthesis_candidates, get_synthesis_candidate,
|
list_synthesis_candidates, get_synthesis_candidate
|
||||||
list_pack_patches, list_curriculum_drafts, list_skill_bundles
|
|
||||||
)
|
)
|
||||||
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
|
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
|
||||||
from .synthesis import generate_synthesis_candidates
|
from .synthesis import generate_synthesis_candidates
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
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=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
_refresh_tokens = {}
|
_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")
|
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||||
return row
|
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")
|
@app.post("/api/knowledge-candidates/{candidate_id}/reviews")
|
||||||
def api_create_review(candidate_id: int, payload: ReviewCreate, reviewer = Depends(require_reviewer)):
|
def api_create_review(candidate_id: int, payload: ReviewCreate, reviewer = Depends(require_reviewer)):
|
||||||
if get_candidate(candidate_id) is None:
|
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)):
|
def api_list_promotions(reviewer = Depends(require_reviewer)):
|
||||||
return list_promotions()
|
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")
|
@app.post("/api/synthesis/run")
|
||||||
def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_reviewer)):
|
def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_reviewer)):
|
||||||
created = generate_synthesis_candidates(payload.source_pack_id, payload.target_pack_id, payload.limit)
|
created = generate_synthesis_candidates(payload.source_pack_id, payload.target_pack_id, payload.limit)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ class KnowledgeCandidateCreate(BaseModel):
|
||||||
synthesis_score: float = 0.0
|
synthesis_score: float = 0.0
|
||||||
triage_lane: str = "archive"
|
triage_lane: str = "archive"
|
||||||
|
|
||||||
|
class KnowledgeCandidateUpdate(BaseModel):
|
||||||
|
triage_lane: str | None = None
|
||||||
|
current_status: str | None = None
|
||||||
|
|
||||||
class ReviewCreate(BaseModel):
|
class ReviewCreate(BaseModel):
|
||||||
review_kind: str = "human_review"
|
review_kind: str = "human_review"
|
||||||
verdict: str
|
verdict: str
|
||||||
|
|
@ -49,3 +53,15 @@ class SynthesisPromoteRequest(BaseModel):
|
||||||
class CreateLearnerRequest(BaseModel):
|
class CreateLearnerRequest(BaseModel):
|
||||||
learner_id: str
|
learner_id: str
|
||||||
display_name: 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)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ class PackORM(Base):
|
||||||
data_json: Mapped[str] = mapped_column(Text)
|
data_json: Mapped[str] = mapped_column(Text)
|
||||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
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):
|
class KnowledgeCandidateORM(Base):
|
||||||
__tablename__ = "knowledge_candidates"
|
__tablename__ = "knowledge_candidates"
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
|
@ -78,43 +95,3 @@ class SynthesisCandidateORM(Base):
|
||||||
evidence_json: Mapped[str] = mapped_column(Text, default="{}")
|
evidence_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
current_status: Mapped[str] = mapped_column(String(50), default="proposed")
|
current_status: Mapped[str] = mapped_column(String(50), default="proposed")
|
||||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
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="")
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ from datetime import datetime, timezone
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
from .orm import (
|
from .orm import (
|
||||||
UserORM, PackORM, LearnerORM,
|
UserORM, PackORM, LearnerORM, MasteryRecordORM,
|
||||||
KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM,
|
KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM
|
||||||
PackPatchProposalORM, CurriculumDraftORM, SkillBundleORM
|
|
||||||
)
|
)
|
||||||
from .auth import verify_password
|
from .auth import verify_password
|
||||||
|
|
||||||
|
|
@ -31,12 +30,33 @@ def list_packs():
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
return db.execute(select(PackORM).order_by(PackORM.id)).scalars().all()
|
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 = ""):
|
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if db.get(LearnerORM, learner_id) is None:
|
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.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
|
||||||
db.commit()
|
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):
|
def create_candidate(payload):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
row = KnowledgeCandidateORM(
|
row = KnowledgeCandidateORM(
|
||||||
|
|
@ -64,26 +84,24 @@ def create_candidate(payload):
|
||||||
def list_candidates():
|
def list_candidates():
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all()
|
rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all()
|
||||||
out = []
|
return [{
|
||||||
for r in rows:
|
"candidate_id": r.id,
|
||||||
out.append({
|
"source_type": r.source_type,
|
||||||
"candidate_id": r.id,
|
"source_artifact_id": r.source_artifact_id,
|
||||||
"source_type": r.source_type,
|
"learner_id": r.learner_id,
|
||||||
"learner_id": r.learner_id,
|
"pack_id": r.pack_id,
|
||||||
"pack_id": r.pack_id,
|
"candidate_kind": r.candidate_kind,
|
||||||
"candidate_kind": r.candidate_kind,
|
"title": r.title,
|
||||||
"title": r.title,
|
"summary": r.summary,
|
||||||
"summary": r.summary,
|
"structured_payload": json.loads(r.structured_payload_json or "{}"),
|
||||||
"structured_payload": json.loads(r.structured_payload_json or "{}"),
|
"evidence_summary": r.evidence_summary,
|
||||||
"evidence_summary": r.evidence_summary,
|
"confidence_hint": r.confidence_hint,
|
||||||
"confidence_hint": r.confidence_hint,
|
"novelty_score": r.novelty_score,
|
||||||
"novelty_score": r.novelty_score,
|
"synthesis_score": r.synthesis_score,
|
||||||
"synthesis_score": r.synthesis_score,
|
"triage_lane": r.triage_lane,
|
||||||
"triage_lane": r.triage_lane,
|
"current_status": r.current_status,
|
||||||
"current_status": r.current_status,
|
"created_at": r.created_at,
|
||||||
"created_at": r.created_at,
|
} for r in rows]
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
def get_candidate(candidate_id: int):
|
def get_candidate(candidate_id: int):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
|
|
@ -93,6 +111,7 @@ def get_candidate(candidate_id: int):
|
||||||
return {
|
return {
|
||||||
"candidate_id": r.id,
|
"candidate_id": r.id,
|
||||||
"source_type": r.source_type,
|
"source_type": r.source_type,
|
||||||
|
"source_artifact_id": r.source_artifact_id,
|
||||||
"learner_id": r.learner_id,
|
"learner_id": r.learner_id,
|
||||||
"pack_id": r.pack_id,
|
"pack_id": r.pack_id,
|
||||||
"candidate_kind": r.candidate_kind,
|
"candidate_kind": r.candidate_kind,
|
||||||
|
|
@ -108,6 +127,19 @@ def get_candidate(candidate_id: int):
|
||||||
"created_at": r.created_at,
|
"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):
|
def create_review(candidate_id: int, reviewer_id: int, payload):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
row = ReviewRecordORM(
|
row = ReviewRecordORM(
|
||||||
|
|
@ -138,93 +170,21 @@ def list_reviews(candidate_id: int):
|
||||||
"created_at": r.created_at,
|
"created_at": r.created_at,
|
||||||
} for r in rows]
|
} 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):
|
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:
|
with SessionLocal() as db:
|
||||||
row = PromotionRecordORM(
|
row = PromotionRecordORM(
|
||||||
candidate_id=candidate_id,
|
candidate_id=candidate_id,
|
||||||
promotion_target=payload.promotion_target,
|
promotion_target=payload.promotion_target,
|
||||||
target_object_id=target_object_id,
|
target_object_id=payload.target_object_id,
|
||||||
promotion_status=payload.promotion_status,
|
promotion_status=payload.promotion_status,
|
||||||
promoted_by=promoted_by,
|
promoted_by=promoted_by,
|
||||||
created_at=now_iso(),
|
created_at=now_iso(),
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
cand = db.get(KnowledgeCandidateORM, candidate_id)
|
candidate = db.get(KnowledgeCandidateORM, candidate_id)
|
||||||
if cand:
|
if candidate:
|
||||||
cand.current_status = "promoted" if payload.promotion_target != "archive" else "archived"
|
candidate.current_status = "promoted"
|
||||||
cand.triage_lane = payload.promotion_target
|
candidate.triage_lane = payload.promotion_target
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return row.id
|
return row.id
|
||||||
|
|
@ -242,55 +202,6 @@ def list_promotions():
|
||||||
"created_at": r.created_at,
|
"created_at": r.created_at,
|
||||||
} for r in rows]
|
} 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(
|
def create_synthesis_candidate(
|
||||||
source_concept_id: str,
|
source_concept_id: str,
|
||||||
target_concept_id: str,
|
target_concept_id: str,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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
|
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
|
target_packs = [by_id[target_pack_id]] if target_pack_id and target_pack_id in by_id else packs
|
||||||
|
|
||||||
created = []
|
created = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for sp in source_packs:
|
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
|
total = 0.35 * sem + 0.25 * struct + 0.20 * traj + 0.10 * review_prior + 0.10 * novelty
|
||||||
if total < 0.45:
|
if total < 0.45:
|
||||||
continue
|
continue
|
||||||
|
explanation = f"Possible cross-pack overlap between '{ca.get('title')}' and '{cb.get('title')}'."
|
||||||
sid = create_synthesis_candidate(
|
sid = create_synthesis_candidate(
|
||||||
source_concept_id=ca.get("id", ""),
|
source_concept_id=ca.get("id", ""),
|
||||||
target_concept_id=cb.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_structural=struct,
|
||||||
score_trajectory=traj,
|
score_trajectory=traj,
|
||||||
score_review_history=review_prior,
|
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")},
|
evidence={"novelty": novelty, "source_title": ca.get("title"), "target_title": cb.get("title")},
|
||||||
)
|
)
|
||||||
seen.add((ca.get("id"), cb.get("id")))
|
seen.add((ca.get("id"), cb.get("id")))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Didactopus Promotion Target Objects</title>
|
<title>Didactopus Review Workbench</title>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body><div id="root"></div></body>
|
<body><div id="root"></div></body>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "didactopus-promotion-target-objects-ui",
|
"name": "didactopus-review-workbench-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,130 @@
|
||||||
import React, { useEffect, useState } from "react";
|
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 }) {
|
function LoginView({ onAuth }) {
|
||||||
const [username, setUsername] = useState("reviewer");
|
const [username, setUsername] = useState("reviewer");
|
||||||
const [password, setPassword] = useState("demo-pass");
|
const [password, setPassword] = useState("demo-pass");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
try { onAuth(await login(username, password)); }
|
try {
|
||||||
catch { setError("Login failed"); }
|
const result = await login(username, password);
|
||||||
|
onAuth(result);
|
||||||
|
} catch {
|
||||||
|
setError("Login failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="page narrow"><section className="card">
|
<div className="page narrow">
|
||||||
<h1>Didactopus promotion targets</h1>
|
<section className="card">
|
||||||
<label>Username<input value={username} onChange={(e)=>setUsername(e.target.value)} /></label>
|
<h1>Didactopus review workbench</h1>
|
||||||
<label>Password<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} /></label>
|
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
|
||||||
<button className="primary" onClick={doLogin}>Login</button>
|
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
|
||||||
{error ? <div className="error">{error}</div> : null}
|
<button className="primary" onClick={doLogin}>Login</button>
|
||||||
</section></div>
|
{error ? <div className="error">{error}</div> : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CandidateCard({ candidate, onPromote }) {
|
function CandidateCard({ candidate, onReview, onPromote }) {
|
||||||
return (
|
return (
|
||||||
<div className="card small">
|
<div className="card small">
|
||||||
<h3>{candidate.title}</h3>
|
<h3>{candidate.title}</h3>
|
||||||
<div className="muted">{candidate.candidate_kind} · {candidate.triage_lane}</div>
|
<div className="muted">{candidate.candidate_kind} · lane: {candidate.triage_lane} · status: {candidate.current_status}</div>
|
||||||
<p>{candidate.summary}</p>
|
<p>{candidate.summary}</p>
|
||||||
|
<div className="tiny">confidence {candidate.confidence_hint.toFixed(2)} · novelty {candidate.novelty_score.toFixed(2)} · synthesis {candidate.synthesis_score.toFixed(2)}</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={() => onPromote(candidate.candidate_id, "pack_improvement")}>Make patch proposal</button>
|
<button onClick={() => onReview(candidate.candidate_id, "accept_pack_improvement")}>Accept as pack improvement</button>
|
||||||
<button onClick={() => onPromote(candidate.candidate_id, "curriculum_draft")}>Make curriculum draft</button>
|
<button onClick={() => onPromote(candidate.candidate_id, "curriculum_draft")}>Promote to curriculum draft</button>
|
||||||
<button onClick={() => onPromote(candidate.candidate_id, "reusable_skill_bundle")}>Make skill bundle</button>
|
<button onClick={() => onPromote(candidate.candidate_id, "reusable_skill_bundle")}>Promote to skill bundle</button>
|
||||||
<button onClick={() => onPromote(candidate.candidate_id, "archive")}>Archive</button>
|
<button onClick={() => onPromote(candidate.candidate_id, "archive")}>Archive</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SynthesisCard({ item, onPromote }) {
|
||||||
|
return (
|
||||||
|
<div className="card small">
|
||||||
|
<h3>{item.source_concept_id} ↔ {item.target_concept_id}</h3>
|
||||||
|
<div className="muted">{item.source_pack_id} → {item.target_pack_id}</div>
|
||||||
|
<p>{item.explanation}</p>
|
||||||
|
<div className="tiny">total {item.score_total.toFixed(2)} · semantic {item.score_semantic.toFixed(2)} · structural {item.score_structural.toFixed(2)}</div>
|
||||||
|
<button onClick={() => onPromote(item.synthesis_id)}>Promote into workflow</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [auth, setAuth] = useState(null);
|
const [auth, setAuth] = useState(null);
|
||||||
const [candidates, setCandidates] = useState([]);
|
const [candidates, setCandidates] = useState([]);
|
||||||
const [synthesis, setSynthesis] = useState([]);
|
const [synthesis, setSynthesis] = useState([]);
|
||||||
const [patches, setPatches] = useState([]);
|
|
||||||
const [curriculum, setCurriculum] = useState([]);
|
|
||||||
const [skills, setSkills] = useState([]);
|
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
async function reload(token = auth?.access_token) {
|
async function reload(token = auth?.access_token) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const [c, s, p, d, k] = await Promise.all([
|
setCandidates(await listCandidates(token));
|
||||||
listCandidates(token),
|
setSynthesis(await listSynthesisCandidates(token));
|
||||||
listSynthesisCandidates(token),
|
|
||||||
listPackPatches(token),
|
|
||||||
listCurriculumDrafts(token),
|
|
||||||
listSkillBundles(token),
|
|
||||||
]);
|
|
||||||
setCandidates(c);
|
|
||||||
setSynthesis(s);
|
|
||||||
setPatches(p);
|
|
||||||
setCurriculum(d);
|
|
||||||
setSkills(k);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { if (auth?.access_token) reload(auth.access_token); }, [auth]);
|
useEffect(() => {
|
||||||
|
if (auth?.access_token) {
|
||||||
|
reload(auth.access_token);
|
||||||
|
}
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
async function seedCandidate() {
|
async function seedCandidate() {
|
||||||
await createCandidate(auth.access_token, {
|
const payload = {
|
||||||
source_type: "learner_export",
|
source_type: "learner_export",
|
||||||
|
source_artifact_id: null,
|
||||||
learner_id: "wesley-learner",
|
learner_id: "wesley-learner",
|
||||||
pack_id: "biology-pack",
|
pack_id: "biology-pack",
|
||||||
candidate_kind: "hidden_prerequisite",
|
candidate_kind: "hidden_prerequisite",
|
||||||
title: "Probability intuition before drift",
|
title: "Possible hidden prerequisite for drift",
|
||||||
summary: "Learner evidence suggests drift is easier after explicit random-process intuition.",
|
summary: "Learner evidence suggests probability intuition should be explicit before drift.",
|
||||||
structured_payload: {
|
structured_payload: { affected_concept: "drift", suggested_prereq: "variation" },
|
||||||
affected_concept: "drift",
|
evidence_summary: "Repeated confusion on stochastic interpretation.",
|
||||||
prerequisites: ["variation", "random_walk"],
|
confidence_hint: 0.73,
|
||||||
source_concepts: ["drift", "variation"],
|
novelty_score: 0.66,
|
||||||
expected_inputs: ["short explanation", "worked example"],
|
synthesis_score: 0.42,
|
||||||
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,
|
|
||||||
triage_lane: "pack_improvement"
|
triage_lane: "pack_improvement"
|
||||||
});
|
};
|
||||||
|
await createCandidate(auth.access_token, payload);
|
||||||
await reload();
|
await reload();
|
||||||
setMessage("Seed candidate created.");
|
setMessage("Seed candidate created.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doPromote(candidateId, target) {
|
async function handleReview(candidateId, verdict) {
|
||||||
await promoteCandidate(auth.access_token, candidateId, { promotion_target: target, target_object_id: "", promotion_status: "approved" });
|
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();
|
await reload();
|
||||||
setMessage(`Candidate ${candidateId} promoted to ${target}.`);
|
setMessage(`Candidate ${candidateId} promoted to ${target}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSynthesis() {
|
async function handleRunSynthesis() {
|
||||||
await runSynthesis(auth.access_token, { source_pack_id: "biology-pack", target_pack_id: "math-pack", limit: 10 });
|
await runSynthesis(auth.access_token, { source_pack_id: "biology-pack", target_pack_id: "math-pack", limit: 12 });
|
||||||
await reload();
|
await reload();
|
||||||
setMessage("Synthesis run completed.");
|
setMessage("Synthesis run completed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doPromoteSynthesis(synthesisId) {
|
async function handlePromoteSynthesis(synthesisId) {
|
||||||
await promoteSynthesis(auth.access_token, synthesisId, { promotion_target: "pack_improvement" });
|
await promoteSynthesis(auth.access_token, synthesisId, { promotion_target: "pack_improvement" });
|
||||||
await reload();
|
await reload();
|
||||||
setMessage(`Synthesis ${synthesisId} promoted.`);
|
setMessage(`Synthesis candidate ${synthesisId} promoted into workflow.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth) return <LoginView onAuth={setAuth} />;
|
if (!auth) return <LoginView onAuth={setAuth} />;
|
||||||
|
|
@ -114,43 +133,32 @@ export default function App() {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>Promotion target objects</h1>
|
<h1>Review workbench + synthesis engine</h1>
|
||||||
<p>Materialize promotions into patch proposals, curriculum drafts, and reusable skill bundles.</p>
|
<p>Triages learner-derived knowledge into pack improvements, curriculum drafts, skill bundles, or archive, while surfacing cross-pack synthesis proposals.</p>
|
||||||
<div className="muted">{message}</div>
|
<div className="muted">{message}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<button onClick={seedCandidate}>Seed candidate</button>
|
<button onClick={seedCandidate}>Seed candidate</button>
|
||||||
<button onClick={doSynthesis}>Run synthesis</button>
|
<button onClick={handleRunSynthesis}>Run synthesis</button>
|
||||||
<button onClick={() => reload()}>Refresh</button>
|
<button onClick={() => reload()}>Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="grid3">
|
<main className="grid">
|
||||||
<section>
|
<section>
|
||||||
<h2>Candidates</h2>
|
<h2>Knowledge candidates</h2>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{candidates.map(c => <CandidateCard key={c.candidate_id} candidate={c} onPromote={doPromote} />)}
|
{candidates.map((c) => (
|
||||||
</div>
|
<CandidateCard key={c.candidate_id} candidate={c} onReview={handleReview} onPromote={handlePromote} />
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>Synthesis</h2>
|
|
||||||
<div className="stack">
|
|
||||||
{synthesis.map(s => (
|
|
||||||
<div className="card small" key={s.synthesis_id}>
|
|
||||||
<h3>{s.source_concept_id} ↔ {s.target_concept_id}</h3>
|
|
||||||
<div className="muted">{s.source_pack_id} → {s.target_pack_id}</div>
|
|
||||||
<p>{s.explanation}</p>
|
|
||||||
<button onClick={() => doPromoteSynthesis(s.synthesis_id)}>Promote synthesis</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Materialized outputs</h2>
|
<h2>Synthesis candidates</h2>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div className="card small"><h3>Pack patches</h3><pre>{JSON.stringify(patches, null, 2)}</pre></div>
|
{synthesis.map((s) => (
|
||||||
<div className="card small"><h3>Curriculum drafts</h3><pre>{JSON.stringify(curriculum, null, 2)}</pre></div>
|
<SynthesisCard key={s.synthesis_id} item={s} onPromote={handlePromoteSynthesis} />
|
||||||
<div className="card small"><h3>Skill bundles</h3><pre>{JSON.stringify(skills, null, 2)}</pre></div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -11,48 +11,45 @@ export async function login(username, password) {
|
||||||
if (!res.ok) throw new Error("login failed");
|
if (!res.ok) throw new Error("login failed");
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCandidates(token) {
|
export async function listCandidates(token) {
|
||||||
const res = await fetch(`${API}/knowledge-candidates`, { headers: authHeaders(token, false) });
|
const res = await fetch(`${API}/knowledge-candidates`, { headers: authHeaders(token, false) });
|
||||||
if (!res.ok) throw new Error("listCandidates failed");
|
if (!res.ok) throw new Error("listCandidates failed");
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCandidate(token, payload) {
|
export async function createCandidate(token, payload) {
|
||||||
const res = await fetch(`${API}/knowledge-candidates`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(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");
|
if (!res.ok) throw new Error("createCandidate failed");
|
||||||
return await res.json();
|
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) {
|
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) });
|
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");
|
if (!res.ok) throw new Error("promoteCandidate failed");
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSynthesis(token, payload) {
|
export async function runSynthesis(token, payload) {
|
||||||
const res = await fetch(`${API}/synthesis/run`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(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");
|
if (!res.ok) throw new Error("runSynthesis failed");
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSynthesisCandidates(token) {
|
export async function listSynthesisCandidates(token) {
|
||||||
const res = await fetch(`${API}/synthesis/candidates`, { headers: authHeaders(token, false) });
|
const res = await fetch(`${API}/synthesis/candidates`, { headers: authHeaders(token, false) });
|
||||||
if (!res.ok) throw new Error("listSynthesisCandidates failed");
|
if (!res.ok) throw new Error("listSynthesisCandidates failed");
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promoteSynthesis(token, synthesisId, payload) {
|
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) });
|
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");
|
if (!res.ok) throw new Error("promoteSynthesis failed");
|
||||||
return await res.json();
|
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; background:var(--bg); color:var(--text); font-family:Arial, Helvetica, sans-serif; }
|
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; }
|
.narrow { max-width:520px; }
|
||||||
.hero, .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
.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; }
|
.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; }
|
.stack { display:grid; gap:14px; }
|
||||||
.card.small h3 { margin-top:0; }
|
.card.small h3 { margin-top:0; }
|
||||||
label { display:block; font-weight:600; margin-bottom:10px; }
|
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; }
|
.actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
.toolbar { display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap; }
|
.toolbar { display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap; }
|
||||||
.muted { color:var(--muted); }
|
.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; }
|
.error { color:#b42318; margin-top:10px; }
|
||||||
@media (max-width: 1250px) {
|
@media (max-width: 1100px) {
|
||||||
.grid3 { grid-template-columns:1fr; }
|
.grid { grid-template-columns:1fr; }
|
||||||
.hero { flex-direction:column; }
|
.hero { flex-direction:column; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue