Apply ZIP update: 195-didactopus-dual-lane-policy-layer.zip [2026-03-14T13:20:27]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 27bc03f77c
commit ebd754c6ba
11 changed files with 54 additions and 139 deletions

View File

@ -5,10 +5,10 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate
from .repository import ( from .repository import (
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
deployment_policy_profile, list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, get_pack_validation, get_pack_provenance, list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, get_pack_validation, get_pack_provenance,
upsert_pack, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks, upsert_pack, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks,
set_pack_publication, can_publish_pack, set_governance_state, list_pack_versions, add_review_comment, list_review_comments, set_pack_publication, can_publish_pack, set_governance_state, list_pack_versions, add_review_comment, list_review_comments,
create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state,
@ -57,35 +57,6 @@ def ensure_pack_access(user, pack_id: str):
return row return row
raise HTTPException(status_code=403, detail="Pack not accessible by this user") raise HTTPException(status_code=403, detail="Pack not accessible by this user")
@app.get("/api/deployment-policy")
def api_deployment_policy(user = Depends(current_user)):
return deployment_policy_profile().model_dump()
@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest)
def api_agent_capabilities(user = Depends(current_user)):
return AgentCapabilityManifest()
@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse)
def api_agent_learner_plan(payload: AgentLearnerPlanRequest, user = Depends(current_user)):
ensure_learner_access(user, payload.learner_id)
ensure_pack_access(user, payload.pack_id)
state = load_learner_state(payload.learner_id)
pack = get_pack(payload.pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack)
return AgentLearnerPlanResponse(
learner_id=payload.learner_id,
pack_id=payload.pack_id,
next_cards=cards,
suggested_actions=[
"Read current learner state",
"Select the highest-priority next card",
"Attempt the exercise or checkpoint",
"Post evidence and refresh recommendations",
],
)
@app.post("/api/login", response_model=TokenPair) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)

View File

@ -8,7 +8,6 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -19,26 +19,6 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class DeploymentPolicyProfile(BaseModel):
profile_name: str
default_personal_lane_enabled: bool = True
default_community_lane_enabled: bool = True
community_publish_requires_approval: bool = True
personal_publish_direct: bool = True
reviewer_assignment_required: bool = False
description: str = ""
class AgentCapabilityManifest(BaseModel):
supports_pack_listing: bool = True
supports_pack_write_personal: bool = True
supports_pack_submit_community: bool = True
supports_recommendations: bool = True
supports_learner_state_read: bool = True
supports_learner_state_write: bool = True
supports_evaluator_jobs: bool = True
supports_governance_endpoints: bool = True
supports_review_queue: bool = True
class PackConcept(BaseModel): class PackConcept(BaseModel):
id: str id: str
title: str title: str
@ -118,13 +98,3 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None result_score: float | None = None
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" result_notes: str = ""
class AgentLearnerPlanRequest(BaseModel):
learner_id: str
pack_id: str
class AgentLearnerPlanResponse(BaseModel):
learner_id: str
pack_id: str
next_cards: list[dict] = Field(default_factory=list)
suggested_actions: list[str] = Field(default_factory=list)

View File

@ -21,7 +21,7 @@ class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
policy_lane: Mapped[str] = mapped_column(String(50), default="personal") policy_lane: Mapped[str] = mapped_column(String(50), default="personal") # personal | community
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")

View File

@ -4,47 +4,12 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password from .auth import verify_password
from .config import load_settings
settings = load_settings()
def now_iso() -> str: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def deployment_policy_profile() -> DeploymentPolicyProfile:
profile = settings.deployment_policy_profile
if profile == "community_repo":
return DeploymentPolicyProfile(
profile_name="community_repo",
default_personal_lane_enabled=True,
default_community_lane_enabled=True,
community_publish_requires_approval=True,
personal_publish_direct=True,
reviewer_assignment_required=True,
description="Shared repository deployment with stronger community controls."
)
if profile == "team_lab":
return DeploymentPolicyProfile(
profile_name="team_lab",
default_personal_lane_enabled=True,
default_community_lane_enabled=True,
community_publish_requires_approval=True,
personal_publish_direct=True,
reviewer_assignment_required=False,
description="Team deployment with shared review but moderate overhead."
)
return DeploymentPolicyProfile(
profile_name="single_user",
default_personal_lane_enabled=True,
default_community_lane_enabled=True,
community_publish_requires_approval=True,
personal_publish_direct=True,
reviewer_assignment_required=False,
description="Single-user/private-first deployment."
)
def pack_diff(old_pack: dict | None, new_pack: dict) -> dict: def pack_diff(old_pack: dict | None, new_pack: dict) -> dict:
old_pack = old_pack or {} old_pack = old_pack or {}
old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])} old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])}
@ -219,9 +184,6 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
proposed_payload = pack.model_dump() proposed_payload = pack.model_dump()
diff = pack_diff(current_payload, proposed_payload) diff = pack_diff(current_payload, proposed_payload)
gates = gate_summary(validation, provenance) gates = gate_summary(validation, provenance)
task_note = "Community submission awaiting reviewer attention"
if deployment_policy_profile().reviewer_assignment_required:
task_note += " (reviewer assignment required by deployment policy)"
sub = ContributionSubmissionORM( sub = ContributionSubmissionORM(
pack_id=pack.id, pack_id=pack.id,
policy_lane="community", policy_lane="community",
@ -240,7 +202,7 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
submission_id=sub.id, submission_id=sub.id,
reviewer_user_id=None, reviewer_user_id=None,
task_status="open", task_status="open",
task_note=task_note, task_note="Community submission awaiting reviewer attention",
created_at=now_iso(), created_at=now_iso(),
)) ))
db.commit() db.commit()
@ -289,7 +251,7 @@ def can_publish_pack(pack_id: str) -> tuple[bool, str]:
return False, "Pack not found" return False, "Pack not found"
if row.policy_lane == "personal": if row.policy_lane == "personal":
return True, "Personal lane pack may publish directly" return True, "Personal lane pack may publish directly"
if deployment_policy_profile().community_publish_requires_approval and row.governance_state != "approved": if row.governance_state != "approved":
return False, "Community lane pack must be approved before publication" return False, "Community lane pack must be approved before publication"
validation = json.loads(row.validation_json or "{}") validation = json.loads(row.validation_json or "{}")
provenance = json.loads(row.provenance_json or "{}") provenance = json.loads(row.provenance_json or "{}")
@ -304,9 +266,14 @@ def set_pack_publication(pack_id: str, is_published: bool):
if row is None: if row is None:
return False, "Pack not found" return False, "Pack not found"
if is_published: if is_published:
ok, reason = can_publish_pack(pack_id) validation = json.loads(row.validation_json or "{}")
if not ok: provenance = json.loads(row.provenance_json or "{}")
return False, reason gates = gate_summary(validation, provenance)
if row.policy_lane == "community":
if row.governance_state != "approved":
return False, "Community lane pack must be approved before publication"
if not gates.get("ready_for_review", False):
return False, "Community lane gates not satisfied"
row.is_published = is_published row.is_published = is_published
db.commit() db.commit()
return True, "Updated" return True, "Updated"

View File

@ -38,7 +38,9 @@ def main():
title="Wesley Private Pack", title="Wesley Private Pack",
subtitle="Personal pack example without community friction.", subtitle="Personal pack example without community friction.",
level="novice-friendly", level="novice-friendly",
concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")], concepts=[
PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker"),
],
onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]}, onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
compliance=PackCompliance(sources=0, attributionRequired=False, shareAlikeRequired=False, noncommercialOnly=False, flags=[]) compliance=PackCompliance(sources=0, attributionRequired=False, shareAlikeRequired=False, noncommercialOnly=False, flags=[])
), ),

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Deployment Policy + Agent Hooks</title> <title>Didactopus Dual-Lane Policy Layer</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body><div id="root"></div></body> <body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-deployment-policy-ui", "name": "didactopus-dual-lane-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability } from "./api"; import { login, refresh, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability, governanceAction, addReviewComment } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
@ -29,7 +29,6 @@ function LoginView({ onAuth }) {
function NavTabs({ tab, setTab, role }) { function NavTabs({ tab, setTab, role }) {
return ( return (
<div className="tab-row"> <div className="tab-row">
<button className={tab==="policy" ? "active-tab" : ""} onClick={() => setTab("policy")}>Policy & agent hooks</button>
<button className={tab==="personal" ? "active-tab" : ""} onClick={() => setTab("personal")}>Personal packs</button> <button className={tab==="personal" ? "active-tab" : ""} onClick={() => setTab("personal")}>Personal packs</button>
<button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Community contribution</button> <button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Community contribution</button>
{role === "admin" ? <> {role === "admin" ? <>
@ -42,9 +41,7 @@ function NavTabs({ tab, setTab, role }) {
export default function App() { export default function App() {
const [auth, setAuth] = useState(loadAuth()); const [auth, setAuth] = useState(loadAuth());
const [tab, setTab] = useState("policy"); const [tab, setTab] = useState("personal");
const [deploymentPolicy, setDeploymentPolicy] = useState(null);
const [agentCapabilities, setAgentCapabilities] = useState(null);
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [adminPacks, setAdminPacks] = useState([]); const [adminPacks, setAdminPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState(""); const [selectedPackId, setSelectedPackId] = useState("");
@ -58,6 +55,8 @@ export default function App() {
const [submissionGates, setSubmissionGates] = useState(null); const [submissionGates, setSubmissionGates] = useState(null);
const [publishability, setPublishability] = useState(null); const [publishability, setPublishability] = useState(null);
const [reviewTasks, setReviewTasks] = useState([]); const [reviewTasks, setReviewTasks] = useState([]);
const [commentText, setCommentText] = useState("Looks structurally plausible.");
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [personalPack, setPersonalPack] = useState({ const [personalPack, setPersonalPack] = useState({
id: "my-private-pack", id: "my-private-pack",
@ -104,14 +103,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
async function load() { async function load() {
setDeploymentPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
setAgentCapabilities(await guarded((token) => fetchAgentCapabilities(token)));
const p = await guarded((token) => fetchPacks(token)); const p = await guarded((token) => fetchPacks(token));
setPacks(p); setPacks(p);
setSelectedPackId((prev) => prev || p[0]?.id || "");
if (auth.role === "admin") { if (auth.role === "admin") {
const ap = await guarded((token) => fetchAdminPacks(token)); setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setAdminPacks(ap);
setSelectedPackId((prev) => prev || ap[0]?.id || "");
setSubmissions(await guarded((token) => fetchSubmissions(token))); setSubmissions(await guarded((token) => fetchSubmissions(token)));
setReviewTasks(await guarded((token) => fetchReviewTasks(token))); setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
} }
@ -151,6 +147,21 @@ export default function App() {
setMessage(`Community submission created: ${result.submission_id}`); setMessage(`Community submission created: ${result.submission_id}`);
} }
async function doGovernance(status) {
await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId)));
setMessage(`Pack moved to ${status}`);
}
async function addCommentNow() {
const versionNumber = versions[0]?.version_number || 1;
await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
setMessage("Review comment added");
}
async function publishSelected() { async function publishSelected() {
const result = await guarded((token) => publishPack(token, selectedPackId, true)); const result = await guarded((token) => publishPack(token, selectedPackId, true));
setMessage(result.reason || "Publish updated"); setMessage(result.reason || "Publish updated");
@ -164,8 +175,8 @@ export default function App() {
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus deployment policy + agent hooks</h1> <h1>Didactopus dual-lane policy layer</h1>
<p>Policy profiles plus explicit API parity for human UI use and AI learner use.</p> <p>Personal packs stay low-friction. Community packs keep gates, review, and approval workflows.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div> <div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null} {message ? <div className="message">{message}</div> : null}
</div> </div>
@ -179,20 +190,6 @@ export default function App() {
<NavTabs tab={tab} setTab={setTab} role={auth.role} /> <NavTabs tab={tab} setTab={setTab} role={auth.role} />
{tab === "policy" && (
<main className="layout twocol">
<section className="card">
<h2>Deployment policy profile</h2>
<pre className="prebox">{JSON.stringify(deploymentPolicy, null, 2)}</pre>
</section>
<section className="card">
<h2>AI learner capability hooks</h2>
<p className="muted">This installation exposes direct API hooks for an AI learner instead of requiring UI mediation.</p>
<pre className="prebox">{JSON.stringify(agentCapabilities, null, 2)}</pre>
</section>
</main>
)}
{tab === "personal" && ( {tab === "personal" && (
<main className="layout onecol"> <main className="layout onecol">
<section className="card"> <section className="card">
@ -257,7 +254,13 @@ export default function App() {
<main className="layout twocol"> <main className="layout twocol">
<section className="card"> <section className="card">
<h2>Governance and publishability</h2> <h2>Governance and publishability</h2>
<button onClick={publishSelected}>Publish</button> <div className="button-row">
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
<button onClick={() => doGovernance("approved")}>Approve</button>
<button onClick={() => doGovernance("rejected")}>Reject</button>
<button onClick={publishSelected}>Publish</button>
</div>
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
<h3>Publishability</h3> <h3>Publishability</h3>
<pre className="prebox">{JSON.stringify(publishability, null, 2)}</pre> <pre className="prebox">{JSON.stringify(publishability, null, 2)}</pre>
<h3>Validation</h3> <h3>Validation</h3>
@ -269,6 +272,8 @@ export default function App() {
<h2>Versions and comments</h2> <h2>Versions and comments</h2>
<h3>Versions</h3> <h3>Versions</h3>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre> <pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
<button className="primary" onClick={addCommentNow}>Add comment</button>
<h3>Comments</h3> <h3>Comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre> <pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section> </section>

View File

@ -16,8 +16,6 @@ export async function refresh(refreshToken) {
if (!res.ok) throw new Error("refresh failed"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); return await res.json();
} }
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); } export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
export async function upsertPack(token, payload) { const res = await fetch(`${API}/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); } export async function upsertPack(token, payload) { const res = await fetch(`${API}/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
export async function createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution failed"); return await res.json(); } export async function createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution failed"); return await res.json(); }
@ -32,3 +30,5 @@ export async function fetchSubmissionGates(token, submissionId) { const res = aw
export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks failed"); return await res.json(); } export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks failed"); return await res.json(); }
export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); } export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
export async function fetchPublishability(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/publishability`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPublishability failed"); return await res.json(); } export async function fetchPublishability(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/publishability`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPublishability failed"); return await res.json(); }
export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }

View File

@ -23,6 +23,7 @@ input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--bor
.table { width:100%; border-collapse:collapse; } .table { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; } .table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
@media (max-width:1100px) { @media (max-width:1100px) {
.hero { flex-direction:column; } .hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; } .twocol { grid-template-columns:1fr; }