diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index a26be2e..fcfae7a 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -76,12 +76,12 @@ interview-ready after each short practice loop.
- [x] **WEB-03**: User can answer a diagnostic question and see rubric feedback.
- [x] **WEB-04**: User can see learner memory, readiness, and next challenge
after answering.
-- [ ] **WEB-05**: Operator can ingest source material from the web app.
-- [ ] **WEB-06**: Operator can inspect ontology candidate concepts, edges, and
+- [x] **WEB-05**: Operator can ingest source material from the web app.
+- [x] **WEB-06**: Operator can inspect ontology candidate concepts, edges, and
gaps.
-- [ ] **WEB-07**: Operator can generate and inspect teaching asset prompt
+- [x] **WEB-07**: Operator can generate and inspect teaching asset prompt
candidates.
-- [ ] **WEB-08**: Web UI includes loading, empty, and error states for the MVP
+- [x] **WEB-08**: Web UI includes loading, empty, and error states for the MVP
flows.
### General Student Expansion
@@ -118,7 +118,7 @@ interview-ready after each short practice loop.
| ASSET-01..ASSET-03 | Phase 6 | Complete |
| WEB-01..WEB-03 | Phase 7 | Complete |
| WEB-04 | Phase 8 | Complete |
-| WEB-05..WEB-08 | Phase 9 | Pending |
+| WEB-05..WEB-08 | Phase 9 | Complete |
**Coverage:**
- v1 requirements: 28 total
@@ -128,4 +128,4 @@ interview-ready after each short practice loop.
---
*Requirements defined: 2026-04-26*
-*Last updated: 2026-04-26 after Phase 8 execution.*
+*Last updated: 2026-04-26 after Phase 9 execution.*
diff --git a/.planning/STATE.md b/.planning/STATE.md
index b6acb3b..0f1f57f 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
**Core value:** The user should feel and prove that they are becoming more
interview-ready after each short practice loop.
-**Current focus:** Phase 9 planning: Material and Asset Workspace.
+**Current focus:** v2 Frontend MVP implemented; ready for milestone audit.
## Current Decisions
@@ -39,10 +39,11 @@ interview-ready after each short practice loop.
usable web service.
- Phase 7 web app shell and diagnostic start UI is implemented and verified.
- Phase 8 learning progress view is implemented and verified.
+- Phase 9 material and asset workspace is implemented and verified.
## Next Actions
-1. Plan and execute Phase 9: Material and Asset Workspace.
+1. Run a v2 Frontend MVP milestone audit.
2. Verify the production OpenAI image model identifier before real image
generation calls.
3. Add standardized SUMMARY frontmatter or Nyquist validation files if future
@@ -84,6 +85,10 @@ interview-ready after each short practice loop.
- 2026-04-26: Phase 8 implementation verified with `go test ./...`, OpenSpec
validation, app script smoke, and learner memory/readiness/next-challenge API
smoke after an answer.
+- 2026-04-26: Phase 9 implementation verified with `go test ./...`, OpenSpec
+ validation, app script smoke, and material/ontology/teaching-asset API smoke.
+ Chrome DevTools MCP browser screenshot attempt timed out and remains a
+ verification follow-up.
---
*State initialized: 2026-04-26.*
diff --git a/.planning/phases/009-material-asset-workspace/009-CONTEXT.md b/.planning/phases/009-material-asset-workspace/009-CONTEXT.md
new file mode 100644
index 0000000..ea24455
--- /dev/null
+++ b/.planning/phases/009-material-asset-workspace/009-CONTEXT.md
@@ -0,0 +1,28 @@
+# Phase 9 Context: Material and Asset Workspace
+
+**Status:** Ready for execution
+**Started:** 2026-04-26
+
+## Goal
+
+Expose material ingestion, ontology inspection, and teaching asset prompt
+candidate generation in the web app.
+
+## Requirements
+
+- WEB-05: Operator can ingest source material from the web app.
+- WEB-06: Operator can inspect ontology candidate concepts, edges, and gaps.
+- WEB-07: Operator can generate and inspect teaching asset prompt candidates.
+- WEB-08: Web UI includes loading, empty, and error states for the MVP flows.
+
+## UX Direction
+
+Keep content operations as a secondary workspace below the diagnostic answer
+surface. The operator flow should show provenance and candidate status without
+turning the page into an admin dashboard.
+
+## Out of Scope
+
+- Full ontology editor.
+- Human review promotion controls.
+- Actual image generation.
diff --git a/.planning/phases/009-material-asset-workspace/009-PLAN.md b/.planning/phases/009-material-asset-workspace/009-PLAN.md
new file mode 100644
index 0000000..118886d
--- /dev/null
+++ b/.planning/phases/009-material-asset-workspace/009-PLAN.md
@@ -0,0 +1,34 @@
+# Phase 9 Plan: Material and Asset Workspace
+
+**Status:** Ready for execution
+**Phase Goal:** Let operators use ontology and teaching asset prompt workflows
+from the web app.
+
+## Tasks
+
+### 1. Add material ingestion UI
+
+- Add title/source/body fields.
+- Call material ingestion API.
+- Show loading and error states.
+
+### 2. Add ontology inspection UI
+
+- Render concept, edge, and gap counts.
+- Show candidate concept labels and review states.
+- Populate asset concept selector.
+
+### 3. Add teaching asset prompt UI
+
+- Generate prompt candidate for selected concept and asset type.
+- Show prompt text, model key, review state, and verification guard.
+
+### 4. Verify
+
+- Update tests for content-operation frontend wiring.
+- Run Go tests, OpenSpec validation, line-count check, and smoke.
+
+## Out of Scope
+
+- Real image generation.
+- Ontology graph editor.
diff --git a/.planning/phases/009-material-asset-workspace/009-RESEARCH.md b/.planning/phases/009-material-asset-workspace/009-RESEARCH.md
new file mode 100644
index 0000000..d86e0e1
--- /dev/null
+++ b/.planning/phases/009-material-asset-workspace/009-RESEARCH.md
@@ -0,0 +1,20 @@
+# Phase 9 Research: Material and Asset Workspace
+
+## Findings
+
+The existing APIs are sufficient for a browser proof:
+
+- `POST /api/v1/materials`
+- `GET /api/v1/ontology`
+- `POST /api/v1/teaching-assets/prompts`
+- `GET /api/v1/teaching-assets`
+
+The frontend should make candidate state obvious and preserve evidence in
+compact text. A full graph canvas would be premature.
+
+## Recommendation
+
+- Use a text ingestion form.
+- Render candidate concepts as selectable options for asset prompt generation.
+- Show counts for concepts, edges, gaps, and prompts.
+- Show model verification guard in the generated prompt output.
diff --git a/.planning/phases/009-material-asset-workspace/009-SUMMARY.md b/.planning/phases/009-material-asset-workspace/009-SUMMARY.md
new file mode 100644
index 0000000..5fa883b
--- /dev/null
+++ b/.planning/phases/009-material-asset-workspace/009-SUMMARY.md
@@ -0,0 +1,35 @@
+# Phase 9 Summary
+
+**Status:** Complete
+**Completed:** 2026-04-26
+
+## Delivered
+
+- Added material ingestion workspace to the web app.
+- Added ontology candidate summary with concept, edge, and gap counts.
+- Added candidate concept selector for teaching asset prompt generation.
+- Added asset type selector and prompt generation UI.
+- Rendered prompt text, model key, review state, evidence, and model-id
+ verification guard.
+- Added frontend asset test coverage for teaching asset API wiring.
+
+## Verification
+
+```powershell
+gofmt -w cmd internal
+go test ./...
+openspec validate frontend-mvp --strict
+```
+
+Additional smoke check:
+
+- Static app script includes material and teaching asset API wiring.
+- Material ingestion returned 4 concepts and 3 edges.
+- Teaching asset prompt generation returned `asset-prompt-1` with verification
+ guard enabled.
+
+## Deferred
+
+- Browser screenshot audit because Chrome DevTools MCP timed out.
+- Full ontology graph editor.
+- Real image generation.
diff --git a/.planning/phases/009-material-asset-workspace/009-VERIFICATION.md b/.planning/phases/009-material-asset-workspace/009-VERIFICATION.md
new file mode 100644
index 0000000..12a3ab6
--- /dev/null
+++ b/.planning/phases/009-material-asset-workspace/009-VERIFICATION.md
@@ -0,0 +1,31 @@
+# Phase 9 Verification
+
+## Verdict
+
+PASS
+
+## Requirement Coverage
+
+- WEB-05: PASS. The web app includes material ingestion UI wired to the real
+ backend API.
+- WEB-06: PASS. The web app renders ontology candidate concept, edge, and gap
+ counts and candidate concept labels.
+- WEB-07: PASS. The web app can generate and inspect teaching asset prompt
+ candidates.
+- WEB-08: PASS. The MVP frontend includes loading, empty, and error states for
+ diagnostic, progress, material, and asset prompt flows.
+
+## Evidence
+
+- `go test ./...` passed.
+- `openspec validate frontend-mvp --strict` passed.
+- Live material ingestion and teaching asset prompt smoke passed.
+- Static app script contains material and teaching asset API integration.
+- Source files remain under 600 lines.
+
+## Residual Risk
+
+Chrome DevTools MCP timed out while opening the local page, so browser
+screenshot verification is still pending. HTTP/API smoke confirms the served
+assets and backend flows, but a visual pass should be repeated when the browser
+tool is responsive.
diff --git a/internal/webapp/assets_test.go b/internal/webapp/assets_test.go
index c860041..d4f1457 100644
--- a/internal/webapp/assets_test.go
+++ b/internal/webapp/assets_test.go
@@ -36,4 +36,7 @@ func TestHandlerServesAsset(t *testing.T) {
if !strings.Contains(rec.Body.String(), "readiness-map") {
t.Fatal("expected progress API content")
}
+ if !strings.Contains(rec.Body.String(), "teaching-assets") {
+ t.Fatal("expected teaching asset API content")
+ }
}
diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js
index 39ca69b..c7090bb 100644
--- a/internal/webapp/static/app.js
+++ b/internal/webapp/static/app.js
@@ -3,6 +3,8 @@ const state = {
selectedQuestion: null,
lastAnswer: null,
progress: null,
+ ontology: null,
+ assetPrompt: null,
};
const els = {
@@ -14,6 +16,12 @@ const els = {
feedback: document.querySelector("#feedback"),
progress: document.querySelector("#progress"),
refreshProgress: document.querySelector("#refresh-progress"),
+ materialForm: document.querySelector("#material-form"),
+ assetForm: document.querySelector("#asset-form"),
+ ontology: document.querySelector("#ontology"),
+ assetOutput: document.querySelector("#asset-output"),
+ assetConcept: document.querySelector("#asset-concept"),
+ assetButton: document.querySelector("#asset-button"),
status: document.querySelector("#status-line"),
error: document.querySelector("#error-line"),
title: document.querySelector("#session-title"),
@@ -41,6 +49,7 @@ els.sessionForm.addEventListener("submit", async (event) => {
state.lastAnswer = null;
renderSession();
renderFeedback();
+ renderProgress();
setStatus(`Session ${session.id} ready`);
} catch (error) {
showError(error.message);
@@ -81,9 +90,54 @@ els.answerForm.addEventListener("submit", async (event) => {
}
});
+els.materialForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ clearError();
+ setStatus("Ingesting material...");
+
+ try {
+ const result = await request("/api/v1/materials", {
+ method: "POST",
+ body: JSON.stringify({
+ title: value("#material-title"),
+ source_type: value("#material-source"),
+ body: value("#material-body"),
+ }),
+ });
+ state.ontology = result.snapshot;
+ renderOntology();
+ setStatus(`Material ${result.material.id} ingested`);
+ } catch (error) {
+ showError(error.message);
+ setStatus("Content workspace ready");
+ }
+});
+
+els.assetForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ clearError();
+ setStatus("Generating prompt candidate...");
+
+ try {
+ const prompt = await request("/api/v1/teaching-assets/prompts", {
+ method: "POST",
+ body: JSON.stringify({
+ concept_id: els.assetConcept.value,
+ asset_type: value("#asset-type"),
+ }),
+ });
+ state.assetPrompt = prompt;
+ renderAssetPrompt();
+ setStatus(`Prompt ${prompt.id} generated`);
+ } catch (error) {
+ showError(error.message);
+ setStatus("Content workspace ready");
+ }
+});
+
function renderSession() {
if (!state.session) return;
- els.title.textContent = `${state.session.target_role} · ${state.session.questions.length} questions`;
+ els.title.textContent = `${state.session.target_role} - ${state.session.questions.length} questions`;
els.questions.className = "question-list";
els.questions.innerHTML = "";
@@ -143,16 +197,64 @@ function renderProgress() {
${escapeHTML(challenge.concept.label)} · ${escapeHTML(challenge.ladder_level)} ${escapeHTML(challenge.concept.label)} - ${escapeHTML(challenge.ladder_level)} ${escapeHTML(challenge.question)}Concept memory
- Next challenge
-
${escapeHTML(prompt.prompt)}
+ ${evidenceBlock(prompt.source_evidence)}
+ `;
+}
+
function renderFeedback() {
if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state";
diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html
index 63485d9..e07d4dc 100644
--- a/internal/webapp/static/index.html
+++ b/internal/webapp/static/index.html
@@ -51,6 +51,56 @@
+
+ Content operations
+