Apply ZIP update: 270-didactopus-promotion-target-objects-layer.zip [2026-03-14T13:21:12]
This commit is contained in:
parent
cbeb474b17
commit
33c0694d83
|
|
@ -12,8 +12,7 @@ dependencies = [
|
|||
"uvicorn>=0.30",
|
||||
"sqlalchemy>=2.0",
|
||||
"passlib[bcrypt]>=1.7",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
"pyyaml>=6.0.2"
|
||||
"python-jose[cryptography]>=3.3"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
import uvicorn
|
||||
from .db import Base, engine
|
||||
from .models import (
|
||||
LoginRequest, TokenPair, KnowledgeCandidateCreate, PromoteRequest,
|
||||
SynthesisRunRequest, SynthesisPromoteRequest, CreateLearnerRequest,
|
||||
ObjectEditRequest, PatchApplyRequest
|
||||
LoginRequest, TokenPair, KnowledgeCandidateCreate,
|
||||
ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest,
|
||||
CreateLearnerRequest
|
||||
)
|
||||
from .repository import (
|
||||
authenticate_user, get_user_by_id, create_learner, create_candidate, list_candidates, get_candidate,
|
||||
create_promotion, list_promotions, list_pack_patches, list_curriculum_drafts, list_skill_bundles,
|
||||
authenticate_user, get_user_by_id, create_learner,
|
||||
create_candidate, list_candidates, get_candidate,
|
||||
create_review, list_reviews, create_promotion, list_promotions,
|
||||
list_synthesis_candidates, get_synthesis_candidate,
|
||||
edit_pack_patch, edit_curriculum_draft, edit_skill_bundle, list_versions,
|
||||
apply_pack_patch, export_curriculum_draft, export_skill_bundle
|
||||
list_pack_patches, list_curriculum_drafts, list_skill_bundles
|
||||
)
|
||||
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 Object Versioning and Export API")
|
||||
|
||||
app = FastAPI(title="Didactopus Promotion Target Objects API")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
_refresh_tokens = {}
|
||||
|
||||
def current_user(authorization: str = Header(default="")):
|
||||
|
|
@ -41,11 +43,16 @@ def require_reviewer(user = Depends(current_user)):
|
|||
@app.post("/api/login", response_model=TokenPair)
|
||||
def login(payload: LoginRequest):
|
||||
user = authenticate_user(payload.username, payload.password)
|
||||
if user is None: raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token_id = new_token_id(); _refresh_tokens[token_id] = user.id
|
||||
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role),
|
||||
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id),
|
||||
username=user.username, role=user.role)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token_id = new_token_id()
|
||||
_refresh_tokens[token_id] = user.id
|
||||
return TokenPair(
|
||||
access_token=issue_access_token(user.id, user.username, user.role),
|
||||
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id),
|
||||
username=user.username,
|
||||
role=user.role
|
||||
)
|
||||
|
||||
@app.post("/api/learners")
|
||||
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
|
||||
|
|
@ -54,16 +61,37 @@ def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_use
|
|||
|
||||
@app.post("/api/knowledge-candidates")
|
||||
def api_create_candidate(payload: KnowledgeCandidateCreate, reviewer = Depends(require_reviewer)):
|
||||
return {"candidate_id": create_candidate(payload)}
|
||||
candidate_id = create_candidate(payload)
|
||||
return {"candidate_id": candidate_id}
|
||||
|
||||
@app.get("/api/knowledge-candidates")
|
||||
def api_list_candidates(reviewer = Depends(require_reviewer)):
|
||||
return list_candidates()
|
||||
|
||||
@app.get("/api/knowledge-candidates/{candidate_id}")
|
||||
def api_get_candidate(candidate_id: int, reviewer = Depends(require_reviewer)):
|
||||
row = get_candidate(candidate_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
return row
|
||||
|
||||
@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:
|
||||
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
review_id = create_review(candidate_id, reviewer.id, payload)
|
||||
return {"review_id": review_id}
|
||||
|
||||
@app.get("/api/knowledge-candidates/{candidate_id}/reviews")
|
||||
def api_list_reviews(candidate_id: int, reviewer = Depends(require_reviewer)):
|
||||
return list_reviews(candidate_id)
|
||||
|
||||
@app.post("/api/knowledge-candidates/{candidate_id}/promote")
|
||||
def api_promote_candidate(candidate_id: int, payload: PromoteRequest, reviewer = Depends(require_reviewer)):
|
||||
if get_candidate(candidate_id) is None: raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
return {"promotion_id": create_promotion(candidate_id, reviewer.id, payload)}
|
||||
if get_candidate(candidate_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
promotion_id = create_promotion(candidate_id, reviewer.id, payload)
|
||||
return {"promotion_id": promotion_id}
|
||||
|
||||
@app.get("/api/promotions")
|
||||
def api_list_promotions(reviewer = Depends(require_reviewer)):
|
||||
|
|
@ -81,46 +109,6 @@ def api_list_curriculum_drafts(reviewer = Depends(require_reviewer)):
|
|||
def api_list_skill_bundles(reviewer = Depends(require_reviewer)):
|
||||
return list_skill_bundles()
|
||||
|
||||
@app.post("/api/pack-patches/{patch_id}/edit")
|
||||
def api_edit_patch(patch_id: int, payload: ObjectEditRequest, reviewer = Depends(require_reviewer)):
|
||||
row = edit_pack_patch(patch_id, payload.payload, reviewer.id, payload.note)
|
||||
if row is None: raise HTTPException(status_code=404, detail="Patch not found")
|
||||
return {"patch_id": row.id, "current_version": row.current_version}
|
||||
|
||||
@app.post("/api/curriculum-drafts/{draft_id}/edit")
|
||||
def api_edit_curriculum(draft_id: int, payload: ObjectEditRequest, reviewer = Depends(require_reviewer)):
|
||||
row = edit_curriculum_draft(draft_id, payload.payload, reviewer.id, payload.note)
|
||||
if row is None: raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return {"draft_id": row.id, "current_version": row.current_version}
|
||||
|
||||
@app.post("/api/skill-bundles/{bundle_id}/edit")
|
||||
def api_edit_skill(bundle_id: int, payload: ObjectEditRequest, reviewer = Depends(require_reviewer)):
|
||||
row = edit_skill_bundle(bundle_id, payload.payload, reviewer.id, payload.note)
|
||||
if row is None: raise HTTPException(status_code=404, detail="Skill bundle not found")
|
||||
return {"skill_bundle_id": row.id, "current_version": row.current_version}
|
||||
|
||||
@app.get("/api/object-versions/{object_kind}/{object_id}")
|
||||
def api_object_versions(object_kind: str, object_id: int, reviewer = Depends(require_reviewer)):
|
||||
return list_versions(object_kind, object_id)
|
||||
|
||||
@app.post("/api/pack-patches/{patch_id}/apply")
|
||||
def api_apply_patch(patch_id: int, payload: PatchApplyRequest, reviewer = Depends(require_reviewer)):
|
||||
row = apply_pack_patch(patch_id, reviewer.id, payload.note)
|
||||
if row is None: raise HTTPException(status_code=404, detail="Patch or pack not found")
|
||||
return {"patch_id": row.id, "status": row.status}
|
||||
|
||||
@app.get("/api/curriculum-drafts/{draft_id}/export")
|
||||
def api_export_curriculum(draft_id: int, reviewer = Depends(require_reviewer)):
|
||||
out = export_curriculum_draft(draft_id)
|
||||
if out is None: raise HTTPException(status_code=404, detail="Draft not found")
|
||||
return out
|
||||
|
||||
@app.get("/api/skill-bundles/{bundle_id}/export")
|
||||
def api_export_skill(bundle_id: int, reviewer = Depends(require_reviewer)):
|
||||
out = export_skill_bundle(bundle_id)
|
||||
if out is None: raise HTTPException(status_code=404, detail="Skill bundle not found")
|
||||
return out
|
||||
|
||||
@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)
|
||||
|
|
@ -130,20 +118,38 @@ def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_r
|
|||
def api_list_synthesis(reviewer = Depends(require_reviewer)):
|
||||
return list_synthesis_candidates()
|
||||
|
||||
@app.get("/api/synthesis/candidates/{synthesis_id}")
|
||||
def api_get_synthesis(synthesis_id: int, reviewer = Depends(require_reviewer)):
|
||||
row = get_synthesis_candidate(synthesis_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Synthesis candidate not found")
|
||||
return row
|
||||
|
||||
@app.post("/api/synthesis/candidates/{synthesis_id}/promote")
|
||||
def api_promote_synthesis(synthesis_id: int, payload: SynthesisPromoteRequest, reviewer = Depends(require_reviewer)):
|
||||
syn = get_synthesis_candidate(synthesis_id)
|
||||
if syn is None: raise HTTPException(status_code=404, detail="Synthesis candidate not found")
|
||||
if syn is None:
|
||||
raise HTTPException(status_code=404, detail="Synthesis candidate not found")
|
||||
candidate_id = create_candidate(KnowledgeCandidateCreate(
|
||||
source_type="synthesis_engine", learner_id="system", pack_id=syn["source_pack_id"],
|
||||
source_type="synthesis_engine",
|
||||
source_artifact_id=None,
|
||||
learner_id="system",
|
||||
pack_id=syn["source_pack_id"],
|
||||
candidate_kind="synthesis_proposal",
|
||||
title=f"Synthesis: {syn['source_concept_id']} ↔ {syn['target_concept_id']}",
|
||||
summary=syn["explanation"], structured_payload=syn,
|
||||
summary=syn["explanation"],
|
||||
structured_payload=syn,
|
||||
evidence_summary="Promoted from synthesis engine candidate",
|
||||
confidence_hint=syn["score_total"], novelty_score=syn["evidence"].get("novelty", 0.0),
|
||||
synthesis_score=syn["score_total"], triage_lane=payload.promotion_target,
|
||||
confidence_hint=syn["score_total"],
|
||||
novelty_score=syn["evidence"].get("novelty", 0.0),
|
||||
synthesis_score=syn["score_total"],
|
||||
triage_lane=payload.promotion_target,
|
||||
))
|
||||
promotion_id = create_promotion(candidate_id, reviewer.id, PromoteRequest(
|
||||
promotion_target=payload.promotion_target,
|
||||
target_object_id="",
|
||||
promotion_status="approved",
|
||||
))
|
||||
promotion_id = create_promotion(candidate_id, reviewer.id, PromoteRequest(promotion_target=payload.promotion_target, target_object_id="", promotion_status="approved"))
|
||||
return {"candidate_id": candidate_id, "promotion_id": promotion_id}
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ class KnowledgeCandidateCreate(BaseModel):
|
|||
synthesis_score: float = 0.0
|
||||
triage_lane: str = "archive"
|
||||
|
||||
class ReviewCreate(BaseModel):
|
||||
review_kind: str = "human_review"
|
||||
verdict: str
|
||||
rationale: str = ""
|
||||
requested_changes: str = ""
|
||||
|
||||
class PromoteRequest(BaseModel):
|
||||
promotion_target: str
|
||||
target_object_id: str = ""
|
||||
|
|
@ -40,13 +46,6 @@ class SynthesisRunRequest(BaseModel):
|
|||
class SynthesisPromoteRequest(BaseModel):
|
||||
promotion_target: str = "pack_improvement"
|
||||
|
||||
class ObjectEditRequest(BaseModel):
|
||||
payload: dict = Field(default_factory=dict)
|
||||
note: str = ""
|
||||
|
||||
class PatchApplyRequest(BaseModel):
|
||||
note: str = "Applied pack patch"
|
||||
|
||||
class CreateLearnerRequest(BaseModel):
|
||||
learner_id: str
|
||||
display_name: str = ""
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@ class KnowledgeCandidateORM(Base):
|
|||
current_status: Mapped[str] = mapped_column(String(50), default="captured")
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class ReviewRecordORM(Base):
|
||||
__tablename__ = "review_records"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True)
|
||||
reviewer_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
review_kind: Mapped[str] = mapped_column(String(50), default="human_review")
|
||||
verdict: Mapped[str] = mapped_column(String(100), default="")
|
||||
rationale: Mapped[str] = mapped_column(Text, default="")
|
||||
requested_changes: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class PromotionRecordORM(Base):
|
||||
__tablename__ = "promotion_records"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
|
@ -50,6 +61,24 @@ class PromotionRecordORM(Base):
|
|||
promoted_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class SynthesisCandidateORM(Base):
|
||||
__tablename__ = "synthesis_candidates"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source_concept_id: Mapped[str] = mapped_column(String(100), index=True)
|
||||
target_concept_id: Mapped[str] = mapped_column(String(100), index=True)
|
||||
source_pack_id: Mapped[str] = mapped_column(String(100), index=True)
|
||||
target_pack_id: Mapped[str] = mapped_column(String(100), index=True)
|
||||
synthesis_kind: Mapped[str] = mapped_column(String(100), default="cross_pack_similarity")
|
||||
score_total: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
score_semantic: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
score_structural: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
score_trajectory: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
score_review_history: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
explanation: Mapped[str] = mapped_column(Text, default="")
|
||||
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)
|
||||
|
|
@ -61,7 +90,6 @@ class PackPatchProposalORM(Base):
|
|||
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")
|
||||
current_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class CurriculumDraftORM(Base):
|
||||
|
|
@ -75,7 +103,6 @@ class CurriculumDraftORM(Base):
|
|||
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")
|
||||
current_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class SkillBundleORM(Base):
|
||||
|
|
@ -90,16 +117,4 @@ class SkillBundleORM(Base):
|
|||
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")
|
||||
current_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
||||
class ObjectVersionORM(Base):
|
||||
__tablename__ = "object_versions"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(50), index=True)
|
||||
object_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
version_number: Mapped[int] = mapped_column(Integer, default=1)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
editor_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[str] = mapped_column(String(100), default="")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ from datetime import datetime, timezone
|
|||
from sqlalchemy import select
|
||||
from .db import SessionLocal
|
||||
from .orm import (
|
||||
UserORM, PackORM, LearnerORM, KnowledgeCandidateORM, PromotionRecordORM,
|
||||
PackPatchProposalORM, CurriculumDraftORM, SkillBundleORM, ObjectVersionORM, SynthesisCandidateORM
|
||||
UserORM, PackORM, LearnerORM,
|
||||
KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM,
|
||||
PackPatchProposalORM, CurriculumDraftORM, SkillBundleORM
|
||||
)
|
||||
from .auth import verify_password
|
||||
|
||||
|
|
@ -30,10 +31,6 @@ 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:
|
||||
|
|
@ -67,20 +64,26 @@ def create_candidate(payload):
|
|||
def list_candidates():
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all()
|
||||
return [{
|
||||
"candidate_id": r.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,
|
||||
} for r in rows]
|
||||
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
|
||||
|
||||
def get_candidate(candidate_id: int):
|
||||
with SessionLocal() as db:
|
||||
|
|
@ -89,6 +92,8 @@ def get_candidate(candidate_id: int):
|
|||
return None
|
||||
return {
|
||||
"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,
|
||||
|
|
@ -100,9 +105,40 @@ def get_candidate(candidate_id: int):
|
|||
"synthesis_score": r.synthesis_score,
|
||||
"triage_lane": r.triage_lane,
|
||||
"current_status": r.current_status,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
|
||||
def create_pack_patch(candidate):
|
||||
def create_review(candidate_id: int, reviewer_id: int, payload):
|
||||
with SessionLocal() as db:
|
||||
row = ReviewRecordORM(
|
||||
candidate_id=candidate_id,
|
||||
reviewer_id=reviewer_id,
|
||||
review_kind=payload.review_kind,
|
||||
verdict=payload.verdict,
|
||||
rationale=payload.rationale,
|
||||
requested_changes=payload.requested_changes,
|
||||
created_at=now_iso(),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
|
||||
def list_reviews(candidate_id: int):
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(select(ReviewRecordORM).where(ReviewRecordORM.candidate_id == candidate_id).order_by(ReviewRecordORM.id.desc())).scalars().all()
|
||||
return [{
|
||||
"review_id": r.id,
|
||||
"candidate_id": r.candidate_id,
|
||||
"reviewer_id": r.reviewer_id,
|
||||
"review_kind": r.review_kind,
|
||||
"verdict": r.verdict,
|
||||
"rationale": r.rationale,
|
||||
"requested_changes": r.requested_changes,
|
||||
"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"],
|
||||
|
|
@ -111,23 +147,16 @@ def create_pack_patch(candidate):
|
|||
title=candidate["title"],
|
||||
proposed_change_json=json.dumps(candidate["structured_payload"]),
|
||||
evidence_summary=candidate["evidence_summary"],
|
||||
reviewer_notes="",
|
||||
reviewer_notes=reviewer_notes,
|
||||
status="proposed",
|
||||
current_version=1,
|
||||
created_at=now_iso(),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("pack_patch", row.id, 1, {
|
||||
"title": row.title,
|
||||
"proposed_change": json.loads(row.proposed_change_json or "{}"),
|
||||
"status": row.status,
|
||||
"reviewer_notes": row.reviewer_notes,
|
||||
}, 1, "Initial version")
|
||||
return f"patch:{row.id}"
|
||||
|
||||
def create_curriculum_draft(candidate):
|
||||
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 [])
|
||||
|
|
@ -139,23 +168,16 @@ def create_curriculum_draft(candidate):
|
|||
audience="general",
|
||||
source_concepts_json=json.dumps(source_concepts),
|
||||
content_markdown=content,
|
||||
editorial_notes="",
|
||||
editorial_notes=reviewer_notes,
|
||||
status="draft",
|
||||
current_version=1,
|
||||
created_at=now_iso(),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("curriculum_draft", row.id, 1, {
|
||||
"topic_focus": row.topic_focus,
|
||||
"content_markdown": row.content_markdown,
|
||||
"product_type": row.product_type,
|
||||
"audience": row.audience,
|
||||
}, 1, "Initial version")
|
||||
return f"curriculum:{row.id}"
|
||||
|
||||
def create_skill_bundle(candidate):
|
||||
def create_skill_bundle(candidate, reviewer_notes: str = ""):
|
||||
with SessionLocal() as db:
|
||||
payload = candidate["structured_payload"]
|
||||
row = SkillBundleORM(
|
||||
|
|
@ -168,21 +190,11 @@ def create_skill_bundle(candidate):
|
|||
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",
|
||||
current_version=1,
|
||||
created_at=now_iso(),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("skill_bundle", row.id, 1, {
|
||||
"skill_name": row.skill_name,
|
||||
"domain": row.domain,
|
||||
"prerequisites": json.loads(row.prerequisites_json or "[]"),
|
||||
"expected_inputs": json.loads(row.expected_inputs_json or "[]"),
|
||||
"failure_modes": json.loads(row.failure_modes_json or "[]"),
|
||||
"validation_checks": json.loads(row.validation_checks_json or "[]"),
|
||||
"canonical_examples": json.loads(row.canonical_examples_json or "[]"),
|
||||
}, 1, "Initial version")
|
||||
return f"skill:{row.id}"
|
||||
|
||||
def create_promotion(candidate_id: int, promoted_by: int, payload):
|
||||
|
|
@ -243,7 +255,6 @@ def list_pack_patches():
|
|||
"evidence_summary": r.evidence_summary,
|
||||
"reviewer_notes": r.reviewer_notes,
|
||||
"status": r.status,
|
||||
"current_version": r.current_version,
|
||||
"created_at": r.created_at,
|
||||
} for r in rows]
|
||||
|
||||
|
|
@ -260,7 +271,6 @@ def list_curriculum_drafts():
|
|||
"content_markdown": r.content_markdown,
|
||||
"editorial_notes": r.editorial_notes,
|
||||
"status": r.status,
|
||||
"current_version": r.current_version,
|
||||
"created_at": r.created_at,
|
||||
} for r in rows]
|
||||
|
||||
|
|
@ -278,222 +288,85 @@ def list_skill_bundles():
|
|||
"validation_checks": json.loads(r.validation_checks_json or "[]"),
|
||||
"canonical_examples": json.loads(r.canonical_examples_json or "[]"),
|
||||
"status": r.status,
|
||||
"current_version": r.current_version,
|
||||
"created_at": r.created_at,
|
||||
} for r in rows]
|
||||
|
||||
def get_pack_patch(patch_id: int):
|
||||
with SessionLocal() as db:
|
||||
r = db.get(PackPatchProposalORM, patch_id)
|
||||
if r is None: return None
|
||||
return {
|
||||
"patch_id": r.id, "pack_id": r.pack_id, "title": r.title,
|
||||
"proposed_change": json.loads(r.proposed_change_json or "{}"),
|
||||
"reviewer_notes": r.reviewer_notes, "status": r.status, "current_version": r.current_version
|
||||
}
|
||||
|
||||
def get_curriculum_draft(draft_id: int):
|
||||
with SessionLocal() as db:
|
||||
r = db.get(CurriculumDraftORM, draft_id)
|
||||
if r is None: return None
|
||||
return {
|
||||
"draft_id": r.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, "current_version": r.current_version
|
||||
}
|
||||
|
||||
def get_skill_bundle(bundle_id: int):
|
||||
with SessionLocal() as db:
|
||||
r = db.get(SkillBundleORM, bundle_id)
|
||||
if r is None: return None
|
||||
return {
|
||||
"skill_bundle_id": r.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, "current_version": r.current_version
|
||||
}
|
||||
|
||||
def _create_version(object_kind: str, object_id: int, version_number: int, payload: dict, editor_id: int, note: str):
|
||||
with SessionLocal() as db:
|
||||
db.add(ObjectVersionORM(
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
version_number=version_number,
|
||||
payload_json=json.dumps(payload),
|
||||
editor_id=editor_id,
|
||||
note=note,
|
||||
created_at=now_iso(),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
def list_versions(object_kind: str, object_id: int):
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(ObjectVersionORM)
|
||||
.where(ObjectVersionORM.object_kind == object_kind, ObjectVersionORM.object_id == object_id)
|
||||
.order_by(ObjectVersionORM.version_number.desc())
|
||||
).scalars().all()
|
||||
return [{
|
||||
"version_id": r.id,
|
||||
"object_kind": r.object_kind,
|
||||
"object_id": r.object_id,
|
||||
"version_number": r.version_number,
|
||||
"payload": json.loads(r.payload_json or "{}"),
|
||||
"editor_id": r.editor_id,
|
||||
"note": r.note,
|
||||
"created_at": r.created_at,
|
||||
} for r in rows]
|
||||
|
||||
def edit_pack_patch(patch_id: int, payload: dict, editor_id: int, note: str):
|
||||
with SessionLocal() as db:
|
||||
row = db.get(PackPatchProposalORM, patch_id)
|
||||
if row is None: return None
|
||||
if "title" in payload: row.title = payload["title"]
|
||||
if "proposed_change" in payload: row.proposed_change_json = json.dumps(payload["proposed_change"])
|
||||
if "reviewer_notes" in payload: row.reviewer_notes = payload["reviewer_notes"]
|
||||
if "status" in payload: row.status = payload["status"]
|
||||
row.current_version += 1
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("pack_patch", patch_id, row.current_version, {
|
||||
"title": row.title,
|
||||
"proposed_change": json.loads(row.proposed_change_json or "{}"),
|
||||
"reviewer_notes": row.reviewer_notes,
|
||||
"status": row.status,
|
||||
}, editor_id, note)
|
||||
return row
|
||||
|
||||
def edit_curriculum_draft(draft_id: int, payload: dict, editor_id: int, note: str):
|
||||
with SessionLocal() as db:
|
||||
row = db.get(CurriculumDraftORM, draft_id)
|
||||
if row is None: return None
|
||||
if "topic_focus" in payload: row.topic_focus = payload["topic_focus"]
|
||||
if "content_markdown" in payload: row.content_markdown = payload["content_markdown"]
|
||||
if "editorial_notes" in payload: row.editorial_notes = payload["editorial_notes"]
|
||||
if "status" in payload: row.status = payload["status"]
|
||||
row.current_version += 1
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("curriculum_draft", draft_id, row.current_version, {
|
||||
"topic_focus": row.topic_focus,
|
||||
"content_markdown": row.content_markdown,
|
||||
"editorial_notes": row.editorial_notes,
|
||||
"status": row.status,
|
||||
}, editor_id, note)
|
||||
return row
|
||||
|
||||
def edit_skill_bundle(bundle_id: int, payload: dict, editor_id: int, note: str):
|
||||
with SessionLocal() as db:
|
||||
row = db.get(SkillBundleORM, bundle_id)
|
||||
if row is None: return None
|
||||
if "skill_name" in payload: row.skill_name = payload["skill_name"]
|
||||
if "prerequisites" in payload: row.prerequisites_json = json.dumps(payload["prerequisites"])
|
||||
if "expected_inputs" in payload: row.expected_inputs_json = json.dumps(payload["expected_inputs"])
|
||||
if "failure_modes" in payload: row.failure_modes_json = json.dumps(payload["failure_modes"])
|
||||
if "validation_checks" in payload: row.validation_checks_json = json.dumps(payload["validation_checks"])
|
||||
if "canonical_examples" in payload: row.canonical_examples_json = json.dumps(payload["canonical_examples"])
|
||||
if "status" in payload: row.status = payload["status"]
|
||||
row.current_version += 1
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
_create_version("skill_bundle", bundle_id, row.current_version, {
|
||||
"skill_name": row.skill_name,
|
||||
"prerequisites": json.loads(row.prerequisites_json or "[]"),
|
||||
"expected_inputs": json.loads(row.expected_inputs_json or "[]"),
|
||||
"failure_modes": json.loads(row.failure_modes_json or "[]"),
|
||||
"validation_checks": json.loads(row.validation_checks_json or "[]"),
|
||||
"canonical_examples": json.loads(row.canonical_examples_json or "[]"),
|
||||
"status": row.status,
|
||||
}, editor_id, note)
|
||||
return row
|
||||
|
||||
def apply_pack_patch(patch_id: int, editor_id: int, note: str):
|
||||
with SessionLocal() as db:
|
||||
patch = db.get(PackPatchProposalORM, patch_id)
|
||||
if patch is None: return None
|
||||
pack = db.get(PackORM, patch.pack_id)
|
||||
if pack is None: return None
|
||||
pack_data = json.loads(pack.data_json or "{}")
|
||||
proposed = json.loads(patch.proposed_change_json or "{}")
|
||||
pack_data.setdefault("applied_patches", []).append({
|
||||
"patch_id": patch.id,
|
||||
"title": patch.title,
|
||||
"proposed_change": proposed,
|
||||
"applied_at": now_iso(),
|
||||
})
|
||||
if "affected_concept" in proposed and "suggested_prereq" in proposed:
|
||||
for concept in pack_data.get("concepts", []):
|
||||
if concept.get("id") == proposed["affected_concept"]:
|
||||
prereqs = concept.setdefault("prerequisites", [])
|
||||
if proposed["suggested_prereq"] not in prereqs:
|
||||
prereqs.append(proposed["suggested_prereq"])
|
||||
pack.data_json = json.dumps(pack_data)
|
||||
patch.status = "applied"
|
||||
db.commit()
|
||||
db.refresh(patch)
|
||||
_create_version("pack_patch", patch_id, patch.current_version, {
|
||||
"title": patch.title,
|
||||
"proposed_change": json.loads(patch.proposed_change_json or "{}"),
|
||||
"status": patch.status,
|
||||
}, editor_id, note)
|
||||
return patch
|
||||
|
||||
def export_curriculum_draft(draft_id: int):
|
||||
draft = get_curriculum_draft(draft_id)
|
||||
if draft is None: return None
|
||||
return {
|
||||
"markdown": draft["content_markdown"],
|
||||
"json": json.dumps(draft, indent=2)
|
||||
}
|
||||
|
||||
def export_skill_bundle(bundle_id: int):
|
||||
import yaml
|
||||
bundle = get_skill_bundle(bundle_id)
|
||||
if bundle is None: return None
|
||||
return {
|
||||
"json": json.dumps(bundle, indent=2),
|
||||
"yaml": yaml.safe_dump(bundle, sort_keys=False)
|
||||
}
|
||||
|
||||
def create_synthesis_candidate(source_concept_id, target_concept_id, source_pack_id, target_pack_id, synthesis_kind, score_semantic, score_structural, score_trajectory, score_review_history, explanation, evidence):
|
||||
def create_synthesis_candidate(
|
||||
source_concept_id: str,
|
||||
target_concept_id: str,
|
||||
source_pack_id: str,
|
||||
target_pack_id: str,
|
||||
synthesis_kind: str,
|
||||
score_semantic: float,
|
||||
score_structural: float,
|
||||
score_trajectory: float,
|
||||
score_review_history: float,
|
||||
explanation: str,
|
||||
evidence: dict
|
||||
):
|
||||
score_total = 0.35 * score_semantic + 0.25 * score_structural + 0.20 * score_trajectory + 0.10 * score_review_history + 0.10 * evidence.get("novelty", 0.0)
|
||||
with SessionLocal() as db:
|
||||
row = SynthesisCandidateORM(
|
||||
source_concept_id=source_concept_id, target_concept_id=target_concept_id,
|
||||
source_pack_id=source_pack_id, target_pack_id=target_pack_id,
|
||||
synthesis_kind=synthesis_kind, score_total=score_total,
|
||||
score_semantic=score_semantic, score_structural=score_structural,
|
||||
score_trajectory=score_trajectory, score_review_history=score_review_history,
|
||||
explanation=explanation, evidence_json=json.dumps(evidence),
|
||||
current_status="proposed", created_at=now_iso(),
|
||||
source_concept_id=source_concept_id,
|
||||
target_concept_id=target_concept_id,
|
||||
source_pack_id=source_pack_id,
|
||||
target_pack_id=target_pack_id,
|
||||
synthesis_kind=synthesis_kind,
|
||||
score_total=score_total,
|
||||
score_semantic=score_semantic,
|
||||
score_structural=score_structural,
|
||||
score_trajectory=score_trajectory,
|
||||
score_review_history=score_review_history,
|
||||
explanation=explanation,
|
||||
evidence_json=json.dumps(evidence),
|
||||
current_status="proposed",
|
||||
created_at=now_iso(),
|
||||
)
|
||||
db.add(row); db.commit(); db.refresh(row); return row.id
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
|
||||
def list_synthesis_candidates():
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(select(SynthesisCandidateORM).order_by(SynthesisCandidateORM.score_total.desc(), SynthesisCandidateORM.id.desc())).scalars().all()
|
||||
return [{
|
||||
"synthesis_id": r.id, "source_concept_id": r.source_concept_id, "target_concept_id": r.target_concept_id,
|
||||
"source_pack_id": r.source_pack_id, "target_pack_id": r.target_pack_id, "synthesis_kind": r.synthesis_kind,
|
||||
"score_total": r.score_total, "score_semantic": r.score_semantic, "score_structural": r.score_structural,
|
||||
"score_trajectory": r.score_trajectory, "score_review_history": r.score_review_history,
|
||||
"explanation": r.explanation, "evidence": json.loads(r.evidence_json or "{}"),
|
||||
"current_status": r.current_status, "created_at": r.created_at,
|
||||
"synthesis_id": r.id,
|
||||
"source_concept_id": r.source_concept_id,
|
||||
"target_concept_id": r.target_concept_id,
|
||||
"source_pack_id": r.source_pack_id,
|
||||
"target_pack_id": r.target_pack_id,
|
||||
"synthesis_kind": r.synthesis_kind,
|
||||
"score_total": r.score_total,
|
||||
"score_semantic": r.score_semantic,
|
||||
"score_structural": r.score_structural,
|
||||
"score_trajectory": r.score_trajectory,
|
||||
"score_review_history": r.score_review_history,
|
||||
"explanation": r.explanation,
|
||||
"evidence": json.loads(r.evidence_json or "{}"),
|
||||
"current_status": r.current_status,
|
||||
"created_at": r.created_at,
|
||||
} for r in rows]
|
||||
|
||||
def get_synthesis_candidate(synthesis_id: int):
|
||||
with SessionLocal() as db:
|
||||
r = db.get(SynthesisCandidateORM, synthesis_id)
|
||||
if r is None: return None
|
||||
if r is None:
|
||||
return None
|
||||
return {
|
||||
"synthesis_id": r.id, "source_concept_id": r.source_concept_id, "target_concept_id": r.target_concept_id,
|
||||
"source_pack_id": r.source_pack_id, "target_pack_id": r.target_pack_id, "synthesis_kind": r.synthesis_kind,
|
||||
"score_total": r.score_total, "score_semantic": r.score_semantic, "score_structural": r.score_structural,
|
||||
"score_trajectory": r.score_trajectory, "score_review_history": r.score_review_history,
|
||||
"explanation": r.explanation, "evidence": json.loads(r.evidence_json or "{}"),
|
||||
"current_status": r.current_status, "created_at": r.created_at,
|
||||
"synthesis_id": r.id,
|
||||
"source_concept_id": r.source_concept_id,
|
||||
"target_concept_id": r.target_concept_id,
|
||||
"source_pack_id": r.source_pack_id,
|
||||
"target_pack_id": r.target_pack_id,
|
||||
"synthesis_kind": r.synthesis_kind,
|
||||
"score_total": r.score_total,
|
||||
"score_semantic": r.score_semantic,
|
||||
"score_structural": r.score_structural,
|
||||
"score_trajectory": r.score_trajectory,
|
||||
"score_review_history": r.score_review_history,
|
||||
"explanation": r.explanation,
|
||||
"evidence": json.loads(r.evidence_json or "{}"),
|
||||
"current_status": r.current_status,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,35 +14,55 @@ def _norm(text: str) -> set[str]:
|
|||
def _semantic_similarity(a: dict, b: dict) -> float:
|
||||
sa = _norm(a.get("title", "")) | _norm(" ".join(a.get("prerequisites", [])))
|
||||
sb = _norm(b.get("title", "")) | _norm(" ".join(b.get("prerequisites", [])))
|
||||
if not sa or not sb: return 0.0
|
||||
if not sa or not sb:
|
||||
return 0.0
|
||||
return len(sa & sb) / len(sa | sb)
|
||||
|
||||
def _structural_similarity(a: dict, b: dict) -> float:
|
||||
pa = set(a.get("prerequisites", [])); pb = set(b.get("prerequisites", []))
|
||||
if not pa and not pb: return 0.6
|
||||
if not pa or not pb: return 0.2
|
||||
pa = set(a.get("prerequisites", []))
|
||||
pb = set(b.get("prerequisites", []))
|
||||
if not pa and not pb:
|
||||
return 0.6
|
||||
if not pa or not pb:
|
||||
return 0.2
|
||||
return len(pa & pb) / len(pa | pb)
|
||||
|
||||
def generate_synthesis_candidates(source_pack_id: str | None = None, target_pack_id: str | None = None, limit: int = 20):
|
||||
packs = list_packs(); by_id = {p.id: p for p in packs}
|
||||
packs = list_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
|
||||
target_packs = [by_id[target_pack_id]] if target_pack_id and target_pack_id in by_id else packs
|
||||
created = []; seen = set()
|
||||
created = []
|
||||
seen = set()
|
||||
for sp in source_packs:
|
||||
for tp in target_packs:
|
||||
if sp.id == tp.id: continue
|
||||
if sp.id == tp.id:
|
||||
continue
|
||||
for ca in _concepts(sp):
|
||||
for cb in _concepts(tp):
|
||||
sem = _semantic_similarity(ca, cb); struct = _structural_similarity(ca, cb)
|
||||
traj = 0.4; review_prior = 0.5; novelty = 1.0 if (ca.get("id"), cb.get("id")) not in seen else 0.0
|
||||
sem = _semantic_similarity(ca, cb)
|
||||
struct = _structural_similarity(ca, cb)
|
||||
traj = 0.4
|
||||
review_prior = 0.5
|
||||
novelty = 1.0 if (ca.get("id"), cb.get("id")) not in seen else 0.0
|
||||
total = 0.35 * sem + 0.25 * struct + 0.20 * traj + 0.10 * review_prior + 0.10 * novelty
|
||||
if total < 0.45: continue
|
||||
if total < 0.45:
|
||||
continue
|
||||
sid = create_synthesis_candidate(
|
||||
ca.get("id", ""), cb.get("id", ""), sp.id, tp.id, "cross_pack_similarity",
|
||||
sem, struct, traj, review_prior,
|
||||
f"Possible cross-pack overlap between '{ca.get('title')}' and '{cb.get('title')}'.",
|
||||
{"novelty": novelty, "source_title": ca.get("title"), "target_title": cb.get("title")}
|
||||
source_concept_id=ca.get("id", ""),
|
||||
target_concept_id=cb.get("id", ""),
|
||||
source_pack_id=sp.id,
|
||||
target_pack_id=tp.id,
|
||||
synthesis_kind="cross_pack_similarity",
|
||||
score_semantic=sem,
|
||||
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')}'.",
|
||||
evidence={"novelty": novelty, "source_title": ca.get("title"), "target_title": cb.get("title")},
|
||||
)
|
||||
seen.add((ca.get("id"), cb.get("id"))); created.append(sid)
|
||||
if len(created) >= limit: return created
|
||||
seen.add((ca.get("id"), cb.get("id")))
|
||||
created.append(sid)
|
||||
if len(created) >= limit:
|
||||
return created
|
||||
return created
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ from pathlib import Path
|
|||
def test_scaffold_files_exist():
|
||||
assert Path("src/didactopus/api.py").exists()
|
||||
assert Path("src/didactopus/repository.py").exists()
|
||||
assert Path("src/didactopus/orm.py").exists()
|
||||
assert Path("src/didactopus/synthesis.py").exists()
|
||||
assert Path("webui/src/App.jsx").exists()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Didactopus Object Versioning</title>
|
||||
<title>Didactopus Promotion Target Objects</title>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "didactopus-object-versioning-ui",
|
||||
"name": "didactopus-promotion-target-objects-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { login, createCandidate, promoteCandidate, listPackPatches, listCurriculumDrafts, listSkillBundles, editPatch, applyPatch, editCurriculum, editSkill, listVersions, exportCurriculum, exportSkill } from "./api";
|
||||
import { login, listCandidates, createCandidate, promoteCandidate, runSynthesis, listSynthesisCandidates, promoteSynthesis, listPackPatches, listCurriculumDrafts, listSkillBundles } from "./api";
|
||||
|
||||
function LoginView({ onAuth }) {
|
||||
const [username, setUsername] = useState("reviewer");
|
||||
|
|
@ -11,7 +11,7 @@ function LoginView({ onAuth }) {
|
|||
}
|
||||
return (
|
||||
<div className="page narrow"><section className="card">
|
||||
<h1>Didactopus object versioning</h1>
|
||||
<h1>Didactopus promotion targets</h1>
|
||||
<label>Username<input value={username} onChange={(e)=>setUsername(e.target.value)} /></label>
|
||||
<label>Password<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} /></label>
|
||||
<button className="primary" onClick={doLogin}>Login</button>
|
||||
|
|
@ -20,134 +20,92 @@ function LoginView({ onAuth }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CandidateCard({ candidate, onPromote }) {
|
||||
return (
|
||||
<div className="card small">
|
||||
<h3>{candidate.title}</h3>
|
||||
<div className="muted">{candidate.candidate_kind} · {candidate.triage_lane}</div>
|
||||
<p>{candidate.summary}</p>
|
||||
<div className="actions">
|
||||
<button onClick={() => onPromote(candidate.candidate_id, "pack_improvement")}>Make patch proposal</button>
|
||||
<button onClick={() => onPromote(candidate.candidate_id, "curriculum_draft")}>Make curriculum draft</button>
|
||||
<button onClick={() => onPromote(candidate.candidate_id, "reusable_skill_bundle")}>Make skill bundle</button>
|
||||
<button onClick={() => onPromote(candidate.candidate_id, "archive")}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [auth, setAuth] = useState(null);
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
const [synthesis, setSynthesis] = useState([]);
|
||||
const [patches, setPatches] = useState([]);
|
||||
const [drafts, setDrafts] = useState([]);
|
||||
const [curriculum, setCurriculum] = useState([]);
|
||||
const [skills, setSkills] = useState([]);
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [exports, setExports] = useState({});
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function reload(token = auth?.access_token) {
|
||||
if (!token) return;
|
||||
const [p, d, s] = await Promise.all([listPackPatches(token), listCurriculumDrafts(token), listSkillBundles(token)]);
|
||||
setPatches(p); setDrafts(d); setSkills(s);
|
||||
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);
|
||||
}
|
||||
|
||||
useEffect(() => { if (auth?.access_token) reload(auth.access_token); }, [auth]);
|
||||
|
||||
async function seedAll() {
|
||||
const candidate = await createCandidate(auth.access_token, {
|
||||
async function seedCandidate() {
|
||||
await createCandidate(auth.access_token, {
|
||||
source_type: "learner_export",
|
||||
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 random-process intuition.",
|
||||
summary: "Learner evidence suggests drift is easier after explicit random-process intuition.",
|
||||
structured_payload: {
|
||||
affected_concept: "drift",
|
||||
suggested_prereq: "random_walk",
|
||||
source_concepts: ["drift", "variation"],
|
||||
prerequisites: ["variation", "random_walk"],
|
||||
expected_inputs: ["text", "example"],
|
||||
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 drift example"]
|
||||
canonical_examples: ["coin flip population drift example"]
|
||||
},
|
||||
evidence_summary: "Repeated learner confusion with stochastic interpretation.",
|
||||
confidence_hint: 0.8,
|
||||
novelty_score: 0.7,
|
||||
synthesis_score: 0.6,
|
||||
confidence_hint: 0.78,
|
||||
novelty_score: 0.69,
|
||||
synthesis_score: 0.55,
|
||||
triage_lane: "pack_improvement"
|
||||
});
|
||||
const candidateId = candidate.candidate_id;
|
||||
await promoteCandidate(auth.access_token, candidateId, { promotion_target: "pack_improvement", target_object_id: "", promotion_status: "approved" });
|
||||
|
||||
const c2 = await createCandidate(auth.access_token, {
|
||||
source_type: "learner_export",
|
||||
learner_id: "wesley-learner",
|
||||
pack_id: "biology-pack",
|
||||
candidate_kind: "lesson_outline",
|
||||
title: "Intro lesson on stochastic evolutionary change",
|
||||
summary: "A lesson framing drift through random processes.",
|
||||
structured_payload: { source_concepts: ["drift", "variation", "random_walk"] },
|
||||
evidence_summary: "Good bridge opportunity for cross-pack synthesis.",
|
||||
confidence_hint: 0.72,
|
||||
novelty_score: 0.6,
|
||||
synthesis_score: 0.75,
|
||||
triage_lane: "curriculum_draft"
|
||||
});
|
||||
await promoteCandidate(auth.access_token, c2.candidate_id, { promotion_target: "curriculum_draft", target_object_id: "", promotion_status: "approved" });
|
||||
|
||||
const c3 = await createCandidate(auth.access_token, {
|
||||
source_type: "learner_export",
|
||||
learner_id: "wesley-learner",
|
||||
pack_id: "biology-pack",
|
||||
candidate_kind: "skill_bundle_candidate",
|
||||
title: "Explain stochastic biological change",
|
||||
summary: "Skill for recognizing and explaining stochastic population change.",
|
||||
structured_payload: {
|
||||
prerequisites: ["variation", "random_walk"],
|
||||
expected_inputs: ["question", "scenario"],
|
||||
failure_modes: ["teleological explanation"],
|
||||
validation_checks: ["distinguishes drift from selection"],
|
||||
canonical_examples: ["small population allele frequency drift"]
|
||||
},
|
||||
evidence_summary: "Could be reusable as an agent skill.",
|
||||
confidence_hint: 0.74,
|
||||
novelty_score: 0.58,
|
||||
synthesis_score: 0.71,
|
||||
triage_lane: "reusable_skill_bundle"
|
||||
});
|
||||
await promoteCandidate(auth.access_token, c3.candidate_id, { promotion_target: "reusable_skill_bundle", target_object_id: "", promotion_status: "approved" });
|
||||
|
||||
await reload();
|
||||
setMessage("Seeded patch, curriculum draft, and skill bundle.");
|
||||
setMessage("Seed candidate created.");
|
||||
}
|
||||
|
||||
async function inspectVersions(kind, id) {
|
||||
const data = await listVersions(auth.access_token, kind, id);
|
||||
setVersions(data);
|
||||
}
|
||||
|
||||
async function revisePatch(id) {
|
||||
await editPatch(auth.access_token, id, {
|
||||
payload: { reviewer_notes: "Elevated priority after synthesis review.", status: "approved" },
|
||||
note: "Reviewer note update"
|
||||
});
|
||||
async function doPromote(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 applySelectedPatch(id) {
|
||||
await applyPatch(auth.access_token, id, { note: "Merged into pack JSON" });
|
||||
async function doSynthesis() {
|
||||
await runSynthesis(auth.access_token, { source_pack_id: "biology-pack", target_pack_id: "math-pack", limit: 10 });
|
||||
await reload();
|
||||
setMessage("Synthesis run completed.");
|
||||
}
|
||||
|
||||
async function reviseDraft(id) {
|
||||
await editCurriculum(auth.access_token, id, {
|
||||
payload: { editorial_notes: "Add random-walk bridge example.", status: "editorial_review" },
|
||||
note: "Editorial refinement"
|
||||
});
|
||||
async function doPromoteSynthesis(synthesisId) {
|
||||
await promoteSynthesis(auth.access_token, synthesisId, { promotion_target: "pack_improvement" });
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function reviseSkill(id) {
|
||||
await editSkill(auth.access_token, id, {
|
||||
payload: { status: "validation", validation_checks: ["distinguishes drift from selection", "uses stochastic terminology correctly"] },
|
||||
note: "Validation criteria strengthened"
|
||||
});
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function doExportDraft(id) {
|
||||
const out = await exportCurriculum(auth.access_token, id);
|
||||
setExports(prev => ({ ...prev, ["draft:"+id]: out }));
|
||||
}
|
||||
|
||||
async function doExportSkill(id) {
|
||||
const out = await exportSkill(auth.access_token, id);
|
||||
setExports(prev => ({ ...prev, ["skill:"+id]: out }));
|
||||
setMessage(`Synthesis ${synthesisId} promoted.`);
|
||||
}
|
||||
|
||||
if (!auth) return <LoginView onAuth={setAuth} />;
|
||||
|
|
@ -156,64 +114,43 @@ export default function App() {
|
|||
<div className="page">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<h1>Object editing, versioning, apply, and export</h1>
|
||||
<p>Promoted objects can now be revised, versioned, merged into packs, and exported in reusable formats.</p>
|
||||
<h1>Promotion target objects</h1>
|
||||
<p>Materialize promotions into patch proposals, curriculum drafts, and reusable skill bundles.</p>
|
||||
<div className="muted">{message}</div>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button onClick={seedAll}>Seed all objects</button>
|
||||
<button onClick={seedCandidate}>Seed candidate</button>
|
||||
<button onClick={doSynthesis}>Run synthesis</button>
|
||||
<button onClick={() => reload()}>Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid3">
|
||||
<section>
|
||||
<h2>Pack patches</h2>
|
||||
<h2>Candidates</h2>
|
||||
<div className="stack">
|
||||
{patches.map(p => (
|
||||
<div key={p.patch_id} className="card small">
|
||||
<h3>{p.title}</h3>
|
||||
<div className="muted">v{p.current_version} · {p.status}</div>
|
||||
<pre>{JSON.stringify(p.proposed_change, null, 2)}</pre>
|
||||
<button onClick={() => revisePatch(p.patch_id)}>Revise</button>
|
||||
<button onClick={() => applySelectedPatch(p.patch_id)}>Apply to pack</button>
|
||||
<button onClick={() => inspectVersions("pack_patch", p.patch_id)}>Versions</button>
|
||||
{candidates.map(c => <CandidateCard key={c.candidate_id} candidate={c} onPromote={doPromote} />)}
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Curriculum drafts</h2>
|
||||
<h2>Materialized outputs</h2>
|
||||
<div className="stack">
|
||||
{drafts.map(d => (
|
||||
<div key={d.draft_id} className="card small">
|
||||
<h3>{d.topic_focus}</h3>
|
||||
<div className="muted">v{d.current_version} · {d.status}</div>
|
||||
<pre>{d.content_markdown}</pre>
|
||||
<button onClick={() => reviseDraft(d.draft_id)}>Revise</button>
|
||||
<button onClick={() => inspectVersions("curriculum_draft", d.draft_id)}>Versions</button>
|
||||
<button onClick={() => doExportDraft(d.draft_id)}>Export</button>
|
||||
{exports["draft:"+d.draft_id] ? <pre>{JSON.stringify(exports["draft:"+d.draft_id], null, 2)}</pre> : null}
|
||||
</div>
|
||||
))}
|
||||
<h2>Skill bundles</h2>
|
||||
{skills.map(s => (
|
||||
<div key={s.skill_bundle_id} className="card small">
|
||||
<h3>{s.skill_name}</h3>
|
||||
<div className="muted">v{s.current_version} · {s.status}</div>
|
||||
<pre>{JSON.stringify(s, null, 2)}</pre>
|
||||
<button onClick={() => reviseSkill(s.skill_bundle_id)}>Revise</button>
|
||||
<button onClick={() => inspectVersions("skill_bundle", s.skill_bundle_id)}>Versions</button>
|
||||
<button onClick={() => doExportSkill(s.skill_bundle_id)}>Export</button>
|
||||
{exports["skill:"+s.skill_bundle_id] ? <pre>{JSON.stringify(exports["skill:"+s.skill_bundle_id], null, 2)}</pre> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Version history</h2>
|
||||
<div className="card small">
|
||||
<pre>{JSON.stringify(versions, null, 2)}</pre>
|
||||
<div className="card small"><h3>Pack patches</h3><pre>{JSON.stringify(patches, null, 2)}</pre></div>
|
||||
<div className="card small"><h3>Curriculum drafts</h3><pre>{JSON.stringify(curriculum, null, 2)}</pre></div>
|
||||
<div className="card small"><h3>Skill bundles</h3><pre>{JSON.stringify(skills, null, 2)}</pre></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
const API = "http://127.0.0.1:8011/api";
|
||||
|
||||
function authHeaders(token, json=true) {
|
||||
const h = { Authorization: `Bearer ${token}` };
|
||||
if (json) h["Content-Type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${API}/login`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ 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");
|
||||
|
|
@ -19,6 +26,21 @@ export async function promoteCandidate(token, candidateId, 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");
|
||||
|
|
@ -34,38 +56,3 @@ export async function listSkillBundles(token) {
|
|||
if (!res.ok) throw new Error("listSkillBundles failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function editPatch(token, patchId, payload) {
|
||||
const res = await fetch(`${API}/pack-patches/${patchId}/edit`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("editPatch failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function applyPatch(token, patchId, payload) {
|
||||
const res = await fetch(`${API}/pack-patches/${patchId}/apply`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("applyPatch failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function editCurriculum(token, draftId, payload) {
|
||||
const res = await fetch(`${API}/curriculum-drafts/${draftId}/edit`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("editCurriculum failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function editSkill(token, bundleId, payload) {
|
||||
const res = await fetch(`${API}/skill-bundles/${bundleId}/edit`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("editSkill failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function listVersions(token, objectKind, objectId) {
|
||||
const res = await fetch(`${API}/object-versions/${objectKind}/${objectId}`, { headers: authHeaders(token, false) });
|
||||
if (!res.ok) throw new Error("listVersions failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function exportCurriculum(token, draftId) {
|
||||
const res = await fetch(`${API}/curriculum-drafts/${draftId}/export`, { headers: authHeaders(token, false) });
|
||||
if (!res.ok) throw new Error("exportCurriculum failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function exportSkill(token, bundleId) {
|
||||
const res = await fetch(`${API}/skill-bundles/${bundleId}/export`, { headers: authHeaders(token, false) });
|
||||
if (!res.ok) throw new Error("exportSkill failed");
|
||||
return await res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,23 @@
|
|||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font-family:Arial, Helvetica, sans-serif; }
|
||||
.page { max-width:1700px; margin:0 auto; padding:24px; }
|
||||
.page { max-width:1600px; 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:1fr 1.2fr 1fr; gap:18px; }
|
||||
.grid3 { display:grid; grid-template-columns:1.1fr 1fr 1.2fr; gap:18px; }
|
||||
.stack { display:grid; gap:14px; }
|
||||
.card.small h3 { margin-top:0; }
|
||||
label { display:block; font-weight:600; margin-bottom:10px; }
|
||||
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
|
||||
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 12px; cursor:pointer; margin-right:8px; margin-top:8px; }
|
||||
button.primary { background:var(--accent); color:white; border-color:var(--accent); }
|
||||
.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; }
|
||||
.error { color:#b42318; margin-top:10px; }
|
||||
@media (max-width: 1350px) {
|
||||
@media (max-width: 1250px) {
|
||||
.grid3 { grid-template-columns:1fr; }
|
||||
.hero { flex-direction:column; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue