273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
const API = "http://127.0.0.1:8765";
|
|
const statuses = ["needs_review", "trusted", "provisional", "rejected"];
|
|
|
|
export default function App() {
|
|
const [registry, setRegistry] = useState({ workspaces: [], recent_workspace_ids: [] });
|
|
const [workspaceId, setWorkspaceId] = useState("");
|
|
const [workspaceTitle, setWorkspaceTitle] = useState("");
|
|
const [importSource, setImportSource] = useState("");
|
|
const [importPreview, setImportPreview] = useState(null);
|
|
const [allowOverwrite, setAllowOverwrite] = useState(false);
|
|
const [session, setSession] = useState(null);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [pendingActions, setPendingActions] = useState([]);
|
|
const [message, setMessage] = useState("Connecting to local Didactopus bridge...");
|
|
|
|
async function loadRegistry() {
|
|
const res = await fetch(`${API}/api/workspaces`);
|
|
const data = await res.json();
|
|
setRegistry(data);
|
|
if (!session) setMessage("Choose, create, preview, or import a workspace.");
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadRegistry().catch(() => setMessage("Could not connect to local review bridge. Start the Python bridge service first."));
|
|
}, []);
|
|
|
|
async function createWorkspace() {
|
|
if (!workspaceId || !workspaceTitle) return;
|
|
await fetch(`${API}/api/workspaces/create`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle })
|
|
});
|
|
await loadRegistry();
|
|
await openWorkspace(workspaceId);
|
|
}
|
|
|
|
async function previewImport() {
|
|
if (!workspaceId || !importSource) return;
|
|
const res = await fetch(`${API}/api/workspaces/import-preview`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ workspace_id: workspaceId, source_dir: importSource })
|
|
});
|
|
const data = await res.json();
|
|
setImportPreview(data);
|
|
setMessage(data.ok ? "Import preview ready." : "Import preview found blocking errors.");
|
|
}
|
|
|
|
async function importWorkspace() {
|
|
if (!workspaceId || !importSource) return;
|
|
const res = await fetch(`${API}/api/workspaces/import`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
workspace_id: workspaceId,
|
|
title: workspaceTitle || workspaceId,
|
|
source_dir: importSource,
|
|
allow_overwrite: allowOverwrite
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!data.ok) {
|
|
setMessage(data.error || "Import failed.");
|
|
return;
|
|
}
|
|
await loadRegistry();
|
|
await openWorkspace(workspaceId);
|
|
setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
|
|
}
|
|
|
|
async function openWorkspace(id) {
|
|
const res = await fetch(`${API}/api/workspaces/open`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ workspace_id: id })
|
|
});
|
|
const opened = await res.json();
|
|
if (!opened.ok) {
|
|
setMessage("Could not open workspace.");
|
|
return;
|
|
}
|
|
const sessionRes = await fetch(`${API}/api/load`);
|
|
const sessionData = await sessionRes.json();
|
|
setSession(sessionData.session);
|
|
setSelectedId(sessionData.session?.draft_pack?.concepts?.[0]?.concept_id || "");
|
|
setPendingActions([]);
|
|
setMessage(`Opened workspace ${id}.`);
|
|
await loadRegistry();
|
|
}
|
|
|
|
const selected = useMemo(() => {
|
|
if (!session) return null;
|
|
return session.draft_pack.concepts.find((c) => c.concept_id === selectedId) || null;
|
|
}, [session, selectedId]);
|
|
|
|
function queueAction(action) {
|
|
setPendingActions((prev) => [...prev, action]);
|
|
}
|
|
|
|
function patchConcept(conceptId, patch, rationale) {
|
|
if (!session) return;
|
|
const concepts = session.draft_pack.concepts.map((c) =>
|
|
c.concept_id === conceptId ? { ...c, ...patch } : c
|
|
);
|
|
setSession({ ...session, draft_pack: { ...session.draft_pack, concepts } });
|
|
|
|
if (patch.status !== undefined) queueAction({ action_type: "set_status", target: conceptId, payload: { status: patch.status }, rationale });
|
|
if (patch.title !== undefined) queueAction({ action_type: "edit_title", target: conceptId, payload: { title: patch.title }, rationale });
|
|
if (patch.description !== undefined) queueAction({ action_type: "edit_description", target: conceptId, payload: { description: patch.description }, rationale });
|
|
if (patch.prerequisites !== undefined) queueAction({ action_type: "edit_prerequisites", target: conceptId, payload: { prerequisites: patch.prerequisites }, rationale });
|
|
if (patch.notes !== undefined) queueAction({ action_type: "edit_notes", target: conceptId, payload: { notes: patch.notes }, rationale });
|
|
}
|
|
|
|
function resolveConflict(conflict) {
|
|
if (!session) return;
|
|
setSession({
|
|
...session,
|
|
draft_pack: { ...session.draft_pack, conflicts: session.draft_pack.conflicts.filter((c) => c !== conflict) }
|
|
});
|
|
queueAction({ action_type: "resolve_conflict", target: "", payload: { conflict }, rationale: "Resolved in UI" });
|
|
}
|
|
|
|
async function saveChanges() {
|
|
if (!pendingActions.length) {
|
|
setMessage("No pending changes to save.");
|
|
return;
|
|
}
|
|
const res = await fetch(`${API}/api/save`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ actions: pendingActions })
|
|
});
|
|
const data = await res.json();
|
|
setSession(data.session);
|
|
setPendingActions([]);
|
|
setMessage("Saved review state.");
|
|
}
|
|
|
|
async function exportPromoted() {
|
|
const res = await fetch(`${API}/api/export`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({})
|
|
});
|
|
const data = await res.json();
|
|
setMessage(`Exported promoted pack to ${data.promoted_pack_dir}`);
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<header className="hero">
|
|
<div>
|
|
<h1>Didactopus Semantic QA</h1>
|
|
<p>
|
|
Reduce the activation-energy hump from generated draft packs to curated review workspaces
|
|
by surfacing semantic curation issues before import.
|
|
</p>
|
|
<div className="small">{message}</div>
|
|
</div>
|
|
<div className="hero-actions">
|
|
<button onClick={saveChanges}>Save Review State</button>
|
|
<button onClick={exportPromoted} disabled={!session}>Export Promoted Pack</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="summary-grid">
|
|
<div className="card">
|
|
<h2>Create Workspace</h2>
|
|
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
|
|
<label>Title<input value={workspaceTitle} onChange={(e) => setWorkspaceTitle(e.target.value)} /></label>
|
|
<button onClick={createWorkspace}>Create</button>
|
|
</div>
|
|
<div className="card">
|
|
<h2>Preview / Import Draft Pack</h2>
|
|
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
|
|
<label>Draft Pack Source Directory<input value={importSource} onChange={(e) => setImportSource(e.target.value)} placeholder="e.g. generated-pack" /></label>
|
|
<label className="checkline"><input type="checkbox" checked={allowOverwrite} onChange={(e) => setAllowOverwrite(e.target.checked)} /> Allow overwrite of existing workspace draft_pack</label>
|
|
<div className="button-row">
|
|
<button onClick={previewImport}>Preview</button>
|
|
<button onClick={importWorkspace}>Import</button>
|
|
</div>
|
|
</div>
|
|
<div className="card">
|
|
<h2>Recent</h2>
|
|
<ul>{registry.recent_workspace_ids.map((id) => <li key={id}><button onClick={() => openWorkspace(id)}>{id}</button></li>)}</ul>
|
|
</div>
|
|
<div className="card">
|
|
<h2>All Workspaces</h2>
|
|
<ul>{registry.workspaces.map((ws) => <li key={ws.workspace_id}><button onClick={() => openWorkspace(ws.workspace_id)}>{ws.title} ({ws.workspace_id})</button></li>)}</ul>
|
|
</div>
|
|
</section>
|
|
|
|
{importPreview && (
|
|
<section className="preview-grid">
|
|
<div className="card">
|
|
<h2>Import Preview</h2>
|
|
<div><strong>OK:</strong> {String(importPreview.ok)}</div>
|
|
<div><strong>Overwrite Required:</strong> {String(importPreview.overwrite_required)}</div>
|
|
<div><strong>Pack:</strong> {importPreview.summary?.display_name || importPreview.summary?.pack_name || "-"}</div>
|
|
<div><strong>Version:</strong> {importPreview.summary?.version || "-"}</div>
|
|
<div><strong>Concepts:</strong> {importPreview.summary?.concept_count ?? "-"}</div>
|
|
<div><strong>Roadmap Stages:</strong> {importPreview.summary?.roadmap_stage_count ?? "-"}</div>
|
|
<div><strong>Projects:</strong> {importPreview.summary?.project_count ?? "-"}</div>
|
|
<div><strong>Rubrics:</strong> {importPreview.summary?.rubric_count ?? "-"}</div>
|
|
</div>
|
|
<div className="card">
|
|
<h2>Validation Errors</h2>
|
|
<ul>{(importPreview.errors || []).length ? importPreview.errors.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
|
|
</div>
|
|
<div className="card">
|
|
<h2>Validation Warnings</h2>
|
|
<ul>{(importPreview.warnings || []).length ? importPreview.warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
|
|
</div>
|
|
<div className="card semantic-card">
|
|
<h2>Semantic QA Warnings</h2>
|
|
<ul>{(importPreview.semantic_warnings || []).length ? importPreview.semantic_warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{session && (
|
|
<main className="layout">
|
|
<aside className="sidebar">
|
|
<h2>Concepts</h2>
|
|
{session.draft_pack.concepts.map((c) => (
|
|
<button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}>
|
|
<span>{c.title}</span>
|
|
<span className={`status-pill status-${c.status}`}>{c.status}</span>
|
|
</button>
|
|
))}
|
|
</aside>
|
|
|
|
<section className="content">
|
|
{selected && (
|
|
<div className="card">
|
|
<h2>Concept Editor</h2>
|
|
<label>Title<input value={selected.title} onChange={(e) => patchConcept(selected.concept_id, { title: e.target.value }, "Edited title")} /></label>
|
|
<label>Status
|
|
<select value={selected.status} onChange={(e) => patchConcept(selected.concept_id, { status: e.target.value }, "Changed trust status")}>
|
|
{statuses.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>Description<textarea rows="6" value={selected.description} onChange={(e) => patchConcept(selected.concept_id, { description: e.target.value }, "Edited description")} /></label>
|
|
<label>Prerequisites (comma-separated ids)<input value={(selected.prerequisites || []).join(", ")} onChange={(e) => patchConcept(selected.concept_id, { prerequisites: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) }, "Edited prerequisites")} /></label>
|
|
<label>Notes (one per line)<textarea rows="4" value={(selected.notes || []).join("\n")} onChange={(e) => patchConcept(selected.concept_id, { notes: e.target.value.split("\n").filter(Boolean) }, "Edited notes")} /></label>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rightbar">
|
|
<div className="card">
|
|
<h2>Conflicts</h2>
|
|
{session.draft_pack.conflicts.length ? session.draft_pack.conflicts.map((conflict, idx) => (
|
|
<div key={idx} className="conflict">
|
|
<div>{conflict}</div>
|
|
<button onClick={() => resolveConflict(conflict)}>Resolve</button>
|
|
</div>
|
|
)) : <div className="small">No remaining conflicts.</div>}
|
|
</div>
|
|
<div className="card">
|
|
<h2>Review Flags</h2>
|
|
<ul>{session.draft_pack.review_flags.map((flag, idx) => <li key={idx}>{flag}</li>)}</ul>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|