diff --git a/pyproject.toml b/pyproject.toml index 72fb8fb..53c7bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 4c88633..af50ea7 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -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(): diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 31f415f..086c82f 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -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 = "" diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 4a39dd4..3fff9bd 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -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="") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index cf2ba6f..e4ca877 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -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, } diff --git a/src/didactopus/synthesis.py b/src/didactopus/synthesis.py index feced7a..2e12151 100644 --- a/src/didactopus/synthesis.py +++ b/src/didactopus/synthesis.py @@ -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 diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 8e2954a..9b11e28 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -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() diff --git a/webui/index.html b/webui/index.html index 5520378..0bbd41c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Object Versioning + Didactopus Promotion Target Objects
diff --git a/webui/package.json b/webui/package.json index 698831a..92a1964 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-object-versioning-ui", + "name": "didactopus-promotion-target-objects-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 541814d..ff3038f 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -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 (
-

Didactopus object versioning

+

Didactopus promotion targets

@@ -20,134 +20,92 @@ function LoginView({ onAuth }) { ); } +function CandidateCard({ candidate, onPromote }) { + return ( +
+

{candidate.title}

+
{candidate.candidate_kind} · {candidate.triage_lane}
+

{candidate.summary}

+
+ + + + +
+
+ ); +} + 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 ; @@ -156,64 +114,43 @@ export default function App() {
-

Object editing, versioning, apply, and export

-

Promoted objects can now be revised, versioned, merged into packs, and exported in reusable formats.

+

Promotion target objects

+

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

{message}
- + +
-

Pack patches

+

Candidates

- {patches.map(p => ( -
-

{p.title}

-
v{p.current_version} · {p.status}
-
{JSON.stringify(p.proposed_change, null, 2)}
- - - + {candidates.map(c => )} +
+
+
+

Synthesis

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

{s.source_concept_id} ↔ {s.target_concept_id}

+
{s.source_pack_id} → {s.target_pack_id}
+

{s.explanation}

+
))}
-

Curriculum drafts

+

Materialized outputs

- {drafts.map(d => ( -
-

{d.topic_focus}

-
v{d.current_version} · {d.status}
-
{d.content_markdown}
- - - - {exports["draft:"+d.draft_id] ?
{JSON.stringify(exports["draft:"+d.draft_id], null, 2)}
: null} -
- ))} -

Skill bundles

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

{s.skill_name}

-
v{s.current_version} · {s.status}
-
{JSON.stringify(s, null, 2)}
- - - - {exports["skill:"+s.skill_bundle_id] ?
{JSON.stringify(exports["skill:"+s.skill_bundle_id], null, 2)}
: null} -
- ))} -
-
-
-

Version history

-
-
{JSON.stringify(versions, null, 2)}
+

Pack patches

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

Curriculum drafts

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

Skill bundles

{JSON.stringify(skills, null, 2)}
diff --git a/webui/src/api.js b/webui/src/api.js index 5aa6dcb..a9234d8 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -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(); -} diff --git a/webui/src/styles.css b/webui/src/styles.css index df0b5ce..6b186a6 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -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; } }