Compare commits

...

10 Commits

Author SHA1 Message Date
user
01d102f5ef style: improve frontend UX/UI - visual states, loading feedback, typography, and accessibility 2026-04-27 11:33:20 +09:00
user
c54da12a4c docs: audit frontend mvp milestone 2026-04-26 18:54:00 +09:00
user
b570c93d94 feat: add material asset workspace 2026-04-26 18:52:16 +09:00
user
7866f6dcb3 feat: show learning progress in web app 2026-04-26 18:41:13 +09:00
user
ce38189f33 feat: add diagnostic web app shell 2026-04-26 18:39:09 +09:00
user
3493f8b5a5 docs: start frontend mvp milestone 2026-04-26 18:34:47 +09:00
user
4bb1d07f94 docs: audit v1 milestone 2026-04-26 18:00:59 +09:00
user
156daa9087 feat: add teaching asset prompts 2026-04-26 17:54:23 +09:00
user
4936cdf4c9 feat: add ontology material ingestion 2026-04-26 17:49:35 +09:00
user
a413f1ef15 feat: add progression readiness api 2026-04-26 16:39:19 +09:00
70 changed files with 4084 additions and 40 deletions

View File

@@ -20,20 +20,38 @@ each short practice loop.
### Validated ### Validated
(None yet; ship to validate.) - [x] Developer job seekers can complete a diagnostic technical interview.
- [x] Answers are graded with rubrics and preserved as evidence.
- [x] Learner memory tracks concept mastery, misconceptions, evidence, and
interventions.
- [x] The system selects the next best interview challenge from learner state.
- [x] The user sees a readiness map and meaningful progression after each loop.
- [x] Uploaded learning materials can become source-backed ontology candidates.
- [x] Generated learning assets preserve prompt, source, and review lineage.
- [x] Backend implementation uses Go and keeps `agent-farm-go` workflow patterns
internalized behind typed interfaces.
### Active ### Active
- [ ] Developer job seekers can complete a diagnostic technical interview. - [ ] Job seekers can use the first web app without API tooling.
- [ ] Answers are graded with rubrics and preserved as evidence. - [ ] The web app guides users through diagnostic practice and shows feedback.
- [ ] Learner memory tracks concept mastery, misconceptions, evidence, and - [ ] The web app shows learner memory, readiness, and next challenge after
interventions. practice.
- [ ] The system selects the next best interview challenge from learner state. - [ ] Operators can ingest learning material and inspect ontology candidates.
- [ ] The user sees a readiness map and meaningful progression after each loop. - [ ] Operators can generate source-backed teaching asset prompt candidates.
- [ ] Uploaded learning materials can become source-backed ontology candidates.
- [ ] Generated learning assets preserve prompt, source, and review lineage. ## Current Milestone: v2 Frontend MVP
- [ ] Backend implementation uses Go and keeps `agent-farm-go` workflow patterns
internalized behind typed interfaces. **Goal:** Turn the completed backend learning loop into a usable web service
experience for developer job seekers.
**Target features:**
- Web app shell served by the Go backend.
- Diagnostic interview practice UI.
- Learner memory, readiness map, and next challenge views.
- Material ingestion and ontology snapshot UI.
- Teaching asset prompt candidate UI.
### Out of Scope ### Out of Scope
@@ -81,6 +99,26 @@ each short practice loop.
| Game-inspired progression must be evidence-backed | Creates retention without empty rewards | Pending | | Game-inspired progression must be evidence-backed | Creates retention without empty rewards | Pending |
| 600-line source limit | Forces responsibility boundaries early | Pending | | 600-line source limit | Forces responsibility boundaries early | Pending |
| Backend Developer Interview first track | Gives a broad but testable MVP concept set | Pending | | Backend Developer Interview first track | Gives a broad but testable MVP concept set | Pending |
| v2 frontend first | The backend loop is proven; the next risk is whether users can operate it as a web service | Active |
## Evolution
This document evolves at phase transitions and milestone boundaries.
**After each phase transition**:
1. Requirements invalidated? Move to Out of Scope with reason.
2. Requirements validated? Move to Validated with phase reference.
3. New requirements emerged? Add to Active.
4. Decisions to log? Add to Key Decisions.
5. "What This Is" still accurate? Update if drifted.
**After each milestone**:
1. Full review of all sections.
2. Core Value check: still the right priority?
3. Audit Out of Scope: reasons still valid?
4. Update Context with current state.
--- ---
*Last updated: 2026-04-26 after first track and Phase 1 plan were set.* *Last updated: 2026-04-26 after v2 Frontend MVP milestone start.*

View File

@@ -38,36 +38,52 @@ interview-ready after each short practice loop.
### Progression ### Progression
- [ ] **PROG-01**: User can see a role-specific readiness map. - [x] **PROG-01**: User can see a role-specific readiness map.
- [ ] **PROG-02**: Concepts have challenge ladders from definition to interview - [x] **PROG-02**: Concepts have challenge ladders from definition to interview
pressure. pressure.
- [ ] **PROG-03**: System selects next challenge based on learner memory and - [x] **PROG-03**: System selects next challenge based on learner memory and
grading evidence. grading evidence.
- [ ] **PROG-04**: System unlocks boss-style integrated questions after - [x] **PROG-04**: System unlocks boss-style integrated questions after
prerequisite stability. prerequisite stability.
- [ ] **PROG-05**: Streaks and rewards avoid punitive or gambling-like mechanics. - [x] **PROG-05**: Streaks and rewards avoid punitive or gambling-like mechanics.
### Ontology and Learning Materials ### Ontology and Learning Materials
- [ ] **ONTO-01**: User or operator can upload learning materials. - [x] **ONTO-01**: User or operator can upload learning materials.
- [ ] **ONTO-02**: System creates source-backed ontology candidate nodes and - [x] **ONTO-02**: System creates source-backed ontology candidate nodes and
edges. edges.
- [ ] **ONTO-03**: System detects missing prerequisites and weakly supported - [x] **ONTO-03**: System detects missing prerequisites and weakly supported
concepts. concepts.
- [ ] **ONTO-04**: Generated or inferred content is marked as candidate until - [x] **ONTO-04**: Generated or inferred content is marked as candidate until
reviewed. reviewed.
### Teaching Assets ### Teaching Assets
- [ ] **ASSET-01**: System can generate prompt candidates for visual teaching - [x] **ASSET-01**: System can generate prompt candidates for visual teaching
assets. assets.
- [ ] **ASSET-02**: Generated assets store source concept, evidence, prompt, - [x] **ASSET-02**: Generated assets store source concept, evidence, prompt,
model config, and review state. model config, and review state.
- [ ] **ASSET-03**: Image model configuration verifies the actual OpenAI model - [x] **ASSET-03**: Image model configuration verifies the actual OpenAI model
identifier before production calls. identifier before production calls.
## v2 Requirements ## v2 Requirements
### Frontend MVP
- [x] **WEB-01**: User can open a web app served by the Go service.
- [x] **WEB-02**: User can create a diagnostic interview session from the web
app.
- [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.
- [x] **WEB-05**: Operator can ingest source material from the web app.
- [x] **WEB-06**: Operator can inspect ontology candidate concepts, edges, and
gaps.
- [x] **WEB-07**: Operator can generate and inspect teaching asset prompt
candidates.
- [x] **WEB-08**: Web UI includes loading, empty, and error states for the MVP
flows.
### General Student Expansion ### General Student Expansion
- **GEN-01**: Support non-interview learning tracks. - **GEN-01**: Support non-interview learning tracks.
@@ -97,15 +113,19 @@ interview-ready after each short practice loop.
| BACK-01..BACK-05 | Phase 1 | Complete | | BACK-01..BACK-05 | Phase 1 | Complete |
| INT-01..INT-06 | Phase 2 | Complete | | INT-01..INT-06 | Phase 2 | Complete |
| MEM-01..MEM-05 | Phase 3 | Complete | | MEM-01..MEM-05 | Phase 3 | Complete |
| PROG-01..PROG-05 | Phase 4 | Pending | | PROG-01..PROG-05 | Phase 4 | Complete |
| ONTO-01..ONTO-04 | Phase 5 | Pending | | ONTO-01..ONTO-04 | Phase 5 | Complete |
| ASSET-01..ASSET-03 | Phase 6 | Pending | | 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 | Complete |
**Coverage:** **Coverage:**
- v1 requirements: 28 total - v1 requirements: 28 total
- Mapped to phases: 28 - v2 frontend requirements: 8 total
- Mapped to phases: 36
- Unmapped: 0 - Unmapped: 0
--- ---
*Requirements defined: 2026-04-26* *Requirements defined: 2026-04-26*
*Last updated: 2026-04-26 after Phase 3 execution.* *Last updated: 2026-04-26 after Phase 9 execution.*

View File

@@ -94,5 +94,50 @@ diagnostic interview.
- Company-specific interview packs. - Company-specific interview packs.
- Human ontology review console. - Human ontology review console.
## Milestone 2: Frontend MVP
### Phase 7: Web App Shell and Diagnostic Start
**Goal:** Serve the first web app from the Go service and let a job seeker
start diagnostic practice without API tooling.
**Requirements:** WEB-01, WEB-02, WEB-03
**Success Criteria:**
- Go service serves a web app at `/`.
- User can enter target role, stack, and interview timeline.
- User can create a diagnostic session from the browser.
- User can submit an answer and see typed grading feedback.
- UI has loading and error states for the diagnostic flow.
### Phase 8: Learning Progress View
**Goal:** Show evidence-backed learning progress after practice.
**Requirements:** WEB-04
**Success Criteria:**
- User can see learner profile and concept mastery after answering.
- User can see readiness percentage and concept ladder state.
- User can see the next recommended challenge and its evidence.
- Empty states explain what to do before memory/progression exists.
### Phase 9: Material and Asset Workspace
**Goal:** Let an operator use ontology and teaching asset prompt workflows from
the web app.
**Requirements:** WEB-05, WEB-06, WEB-07, WEB-08
**Success Criteria:**
- Operator can ingest text material from the browser.
- Operator can inspect ontology candidate concepts, edges, and gaps.
- Operator can generate teaching asset prompt candidates from a concept.
- UI clearly shows candidate review state, source evidence, and model
verification guard.
--- ---
*Roadmap created: 2026-04-26 after initial product planning and Go backend decision.* *Roadmap updated: 2026-04-26 after v2 Frontend MVP milestone start.*

View File

@@ -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 **Core value:** The user should feel and prove that they are becoming more
interview-ready after each short practice loop. interview-ready after each short practice loop.
**Current focus:** Phase 4 planning: Progression. **Current focus:** v2 Frontend MVP audited; ready to choose the next milestone.
## Current Decisions ## Current Decisions
@@ -27,14 +27,30 @@ interview-ready after each short practice loop.
- Phase 3 learner memory is implemented and verified with evidence-backed - Phase 3 learner memory is implemented and verified with evidence-backed
in-memory profiles, mastery, misconceptions, interventions, and review in-memory profiles, mastery, misconceptions, interventions, and review
schedules. schedules.
- Phase 4 progression is implemented and verified with readiness map and next
challenge APIs derived from learner memory evidence.
- Phase 5 ontology material ingestion is implemented and verified with
source-backed candidate concepts, prerequisite edges, and candidate gaps.
- Phase 6 teaching asset prompts are implemented and verified with source
evidence, model config, review state, and model-id verification guard.
- v1 milestone audit completed with all 28/28 requirements satisfied and
tech-debt items recorded in `.planning/v1-MILESTONE-AUDIT.md`.
- v2 Frontend MVP milestone selected to turn the backend learning loop into a
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.
- v2 Frontend MVP audit completed with 8/8 requirements satisfied and
tech-debt items recorded in `.planning/v2-FRONTEND-MVP-AUDIT.md`.
## Next Actions ## Next Actions
1. Plan Phase 4 progression with GSD. 1. Choose the next milestone: persistence/runtime hardening, real workflow
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during runtime, document parser integration, or UI visual hardening.
future workflow implementation. 2. Verify the production OpenAI image model identifier before real image
3. Decide whether Phase 4 readiness map reads directly from learner memory or generation calls.
introduces a derived progression projection. 3. Add standardized SUMMARY frontmatter or Nyquist validation files if future
GSD automation should enforce those gates.
## Validation Log ## Validation Log
@@ -51,6 +67,34 @@ interview-ready after each short practice loop.
- 2026-04-26: Phase 3 implementation verified with `go test ./...`, - 2026-04-26: Phase 3 implementation verified with `go test ./...`,
`openspec validate bootstrap-job-tutor-platform --strict`, live diagnostic `openspec validate bootstrap-job-tutor-platform --strict`, live diagnostic
answer to learner-memory smoke, and Go source line-count check. answer to learner-memory smoke, and Go source line-count check.
- 2026-04-26: Phase 4 implementation verified with `go test ./...`,
`openspec validate bootstrap-job-tutor-platform --strict`, live readiness and
next-challenge smoke, and Go source line-count check.
- 2026-04-26: Phase 5 implementation verified with `go test ./...`,
`openspec validate bootstrap-job-tutor-platform --strict`, live material
ingestion and ontology snapshot smoke, and Go source line-count check.
- 2026-04-26: Phase 6 implementation verified with `go test ./...`,
`openspec validate bootstrap-job-tutor-platform --strict`, live
material-to-asset-prompt smoke, and Go source line-count check.
- 2026-04-26: v1 milestone audit verified 28/28 requirements, cross-phase
integration, E2E diagnostic/progression and material/asset flows. Audit
status is `tech_debt` because MVP storage, real workflow runtime, parsers,
real image generation, and Nyquist validation artifacts remain deferred.
- 2026-04-26: v2 Frontend MVP milestone started with WEB-01..WEB-08 mapped to
phases 7 through 9.
- 2026-04-26: Phase 7 implementation verified with `go test ./...`, OpenSpec
validation, root/asset HTTP smoke, and diagnostic API smoke through the
server used by the web app.
- 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.
- 2026-04-26: v2 Frontend MVP audit verified 8/8 requirements and E2E frontend
API wiring. Audit status is `tech_debt` because browser screenshot
verification timed out and persistence/runtime hardening remain deferred.
--- ---
*State initialized: 2026-04-26.* *State initialized: 2026-04-26.*

View File

@@ -0,0 +1,39 @@
# Phase 4 Context: Progression
**Status:** Ready for execution
**Started:** 2026-04-26
## Goal
Expose visible, evidence-backed progression after diagnostic practice.
## Inputs
- OpenSpec `learning-progression` requirements.
- `docs/planning/GAMIFICATION.md`.
- Phase 3 learner memory snapshots.
- Existing workflow contracts for `NextChallenge` and `ReadinessUpdate`.
## Decisions
- Derive MVP readiness directly from learner memory.
- Keep progression read-only except for future workflow outputs.
- Do not add streak persistence yet.
- Rewards and unlocks must be deterministic and evidence-backed.
## Boundaries
In scope:
- Role readiness map API.
- Concept ladder level calculation.
- Next challenge selection API.
- Boss-style unlock when prerequisite concepts are stable.
- Tests and verification.
Out of scope:
- Frontend map UI.
- Persistent campaign/streak storage.
- Social leaderboards.
- Random reward economy.

View File

@@ -0,0 +1,46 @@
# Phase 4 Plan: Progression
**Status:** Ready for execution
**Phase Goal:** Show evidence-backed readiness and select the next challenge.
## Requirements Covered
- PROG-01: User can see a role-specific readiness map.
- PROG-02: Concepts have challenge ladders from definition to interview
pressure.
- PROG-03: System selects next challenge based on learner memory and grading
evidence.
- PROG-04: System unlocks boss-style integrated questions after prerequisite
stability.
- PROG-05: Streaks and rewards avoid punitive or gambling-like mechanics.
## Tasks
### 1. Add progression package
- Define readiness map, concept progress, reward, and unlock types.
- Compute readiness from learner memory snapshots.
### 2. Add next challenge selection
- Select the weakest evidenced concept first.
- Use review schedule or misconception evidence to choose recovery difficulty.
- Produce typed `workflows.NextChallenge`.
### 3. Add HTTP endpoints
- `GET /api/v1/learners/{userID}/readiness-map`
- `GET /api/v1/learners/{userID}/next-challenge`
### 4. Add tests and verification
- Test readiness map projection.
- Test next challenge selection from weak memory.
- Test HTTP readiness flow after diagnostic answer.
- Run Go tests, OpenSpec validation, line-count check, and smoke.
## Out of Scope
- Frontend visualization.
- Persistent streak history.
- Multi-track progression graph.

View File

@@ -0,0 +1,32 @@
# Phase 4 Research: Progression
## Findings
Learner memory already stores the minimum evidence needed for progression:
- concept mastery state
- evidence references
- misconceptions
- review schedules
- interventions
The MVP progression surface can therefore be computed as a projection rather
than a new durable source of truth.
## Recommended Shape
- `internal/progression` owns readiness projection and challenge selection.
- `learnermemory.Service` remains the source for learner state.
- Readiness percentage should be simple and explainable.
- Challenge ladder should map readiness state to the next useful task:
- unknown/fragile: define or recovery
- improving: tradeoffs
- interview-ready: design constraints
- strong signal: interview pressure
- Boss unlock requires at least two stable concepts with evidence.
## Risks
- Too much gamification logic can become speculative. Keep it deterministic.
- Readiness percentages can feel fake if not traceable. Include evidence.
- Missing memory should return a normal 404, not invented progress.

View File

@@ -0,0 +1,35 @@
# Phase 4 Summary
**Status:** Complete
**Completed:** 2026-04-26
## Delivered
- Added `internal/progression` for readiness projection and next challenge
selection.
- Added role readiness map calculation from learner memory evidence.
- Added deterministic challenge ladder mapping.
- Added evidence-backed rewards and boss-question unlocks.
- Added HTTP endpoints:
- `GET /api/v1/learners/{userID}/readiness-map`
- `GET /api/v1/learners/{userID}/next-challenge`
- Added progression unit tests and HTTP flow coverage.
## Verification
```powershell
gofmt -w cmd internal
go test ./...
openspec validate bootstrap-job-tutor-platform --strict
```
Additional smoke check:
- Diagnostic create/answer followed by readiness-map and next-challenge reads
returned readiness `75`, one concept, and a typed challenge.
## Deferred
- Frontend readiness visualization.
- Persistent campaign and streak state.
- Multi-concept cluster graph beyond simple stable-count boss unlock.

View File

@@ -0,0 +1,28 @@
# Phase 4 Verification
## Verdict
PASS
## Requirement Coverage
- PROG-01: PASS. Readiness map API returns learner concept readiness.
- PROG-02: PASS. Each concept maps to a challenge ladder level.
- PROG-03: PASS. Next challenge selection targets the weakest evidenced
learner-memory concept.
- PROG-04: PASS. Boss unlocks are produced only from stable evidenced concepts.
- PROG-05: PASS. Rewards are deterministic, evidence-backed, and do not punish
missed days or use random reward mechanics.
## Evidence
- `go test ./...` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Live diagnostic create/answer plus readiness and next-challenge smoke passed.
- Go source line-count check passed.
## Residual Risk
Progression is currently an in-memory projection. It is enough for MVP proof but
will need persisted campaign state before real streaks or long-running
readiness histories.

View File

@@ -0,0 +1,37 @@
# Phase 5 Context: Ontology and Learning Materials
**Status:** Ready for execution
**Started:** 2026-04-26
## Goal
Accept learning material input and produce source-backed ontology candidates.
## Inputs
- OpenSpec `learning-ontology` requirements.
- Existing workflow contracts for `OntologyGap`.
- Backend Developer Interview seed concepts.
## Decisions
- Use an in-memory ontology store for MVP proof.
- Accept JSON material ingestion before multipart file upload.
- Mark all generated nodes, edges, and gaps as `candidate`.
- Preserve source evidence for every supported ontology candidate.
## Boundaries
In scope:
- Material ingestion API.
- Source-backed ontology candidate nodes and edges.
- Gap detection for missing prerequisites and weak evidence.
- Ontology snapshot API.
Out of scope:
- File storage.
- PDF/PPT parsing.
- Human review UI.
- Canonical promotion workflow.

View File

@@ -0,0 +1,42 @@
# Phase 5 Plan: Ontology and Learning Materials
**Status:** Ready for execution
**Phase Goal:** Ingest learning materials into source-backed ontology candidates.
## Requirements Covered
- ONTO-01: User or operator can upload learning materials.
- ONTO-02: System creates source-backed ontology candidate nodes and edges.
- ONTO-03: System detects missing prerequisites and weakly supported concepts.
- ONTO-04: Generated or inferred content is marked as candidate until reviewed.
## Tasks
### 1. Add ontology package
- Define material, concept candidate, edge candidate, gap, and snapshot types.
- Add in-memory store and service.
### 2. Implement deterministic MVP analyzer
- Extract known backend interview concept candidates from material text.
- Create prerequisite edges for supported concept pairs.
- Create gap candidates for missing prerequisites and weak evidence.
### 3. Add HTTP endpoints
- `POST /api/v1/materials`
- `GET /api/v1/ontology`
### 4. Add tests and verification
- Test material ingestion creates source-backed candidates.
- Test gaps are candidate-only.
- Test HTTP ingestion and ontology snapshot flow.
- Run Go tests, OpenSpec validation, line-count check, and smoke.
## Out of Scope
- Multipart upload.
- Real document parsers.
- Human review promotion.

View File

@@ -0,0 +1,28 @@
# Phase 5 Research: Ontology and Learning Materials
## Findings
The first useful ontology proof does not need heavy parsing. It needs a clean
boundary that proves uploaded material can become inspectable candidate
knowledge with provenance.
The MVP should:
- store material metadata and source text
- extract concept candidates from known backend interview concepts
- create prerequisite edges from a small deterministic rule set
- identify weak concepts when source support is thin
- never mark generated or inferred content as canonical
## Recommended Shape
- `internal/ontology` owns material ingestion, candidate storage, and snapshot.
- HTTP exposes JSON ingestion first.
- Evidence references use the existing workflow shared type.
- Gap records distinguish source-backed weakness from generated inference.
## Risks
- Overbuilding parsers too early would violate YAGNI.
- Treating keyword extraction as canonical knowledge would violate OpenSpec.
- A future parser can replace the analyzer behind the same service boundary.

View File

@@ -0,0 +1,36 @@
# Phase 5 Summary
**Status:** Complete
**Completed:** 2026-04-26
## Delivered
- Added `internal/ontology` for materials, concept candidates, edge candidates,
gaps, and snapshots.
- Added deterministic MVP analyzer for known backend interview concepts.
- Added source evidence to every supported concept and edge candidate.
- Added candidate-only gap records for missing prerequisites and weak evidence.
- Added HTTP endpoints:
- `POST /api/v1/materials`
- `GET /api/v1/ontology`
- Added ontology unit tests and HTTP flow tests.
## Verification
```powershell
gofmt -w cmd internal
go test ./...
openspec validate bootstrap-job-tutor-platform --strict
```
Additional smoke check:
- Material ingestion followed by ontology snapshot returned candidate concepts,
edges, and gaps.
## Deferred
- Multipart uploads.
- PPT/PDF/document parsing.
- Human review and canonical promotion.
- Graph database persistence.

View File

@@ -0,0 +1,29 @@
# Phase 5 Verification
## Verdict
PASS
## Requirement Coverage
- ONTO-01: PASS. JSON material ingestion API accepts operator-provided learning
material.
- ONTO-02: PASS. Ingestion creates source-backed candidate concepts and
prerequisite edges.
- ONTO-03: PASS. The analyzer creates candidate gaps for missing prerequisites
and weak source evidence.
- ONTO-04: PASS. All generated ontology candidates and gaps use `candidate`
review state.
## Evidence
- `go test ./...` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Live material ingestion and ontology snapshot smoke passed.
- Go source line-count check passed.
## Residual Risk
The analyzer is deterministic and intentionally shallow. It proves the product
boundary but should later be replaced or supplemented with parser-backed and
LLM-assisted extraction.

View File

@@ -0,0 +1,39 @@
# Phase 6 Context: Teaching Assets
**Status:** Ready for execution
**Started:** 2026-04-26
## Goal
Generate reviewable teaching asset prompt candidates from ontology concepts.
## Inputs
- OpenSpec generated study asset lineage requirement.
- `docs/planning/WORKFLOW_CONTRACTS.md` TeachingAssetPrompt contract.
- Phase 5 ontology candidates and source evidence.
- PRD requirement to verify actual OpenAI model identifier before production
image calls.
## Decisions
- Generate prompt candidates only; do not call an image provider in this phase.
- Default product model key remains `gpt-image-v2`.
- Keep `requires_model_id_verification=true` until a future production
integration verifies the actual provider model identifier.
- Persist prompt lineage with concept, evidence, model key, and review state.
## Boundaries
In scope:
- Teaching asset prompt candidate service.
- Asset prompt and snapshot APIs.
- Model verification guard represented in output.
Out of scope:
- Real image generation calls.
- Binary asset storage.
- PPT export.
- Provider-specific OpenAI SDK integration.

View File

@@ -0,0 +1,44 @@
# Phase 6 Plan: Teaching Assets
**Status:** Ready for execution
**Phase Goal:** Create source-backed teaching asset prompt candidates.
## Requirements Covered
- ASSET-01: System can generate prompt candidates for visual teaching assets.
- ASSET-02: Generated assets store source concept, evidence, prompt, model
config, and review state.
- ASSET-03: Image model configuration verifies the actual OpenAI model
identifier before production calls.
## Tasks
### 1. Add teaching asset package
- Define asset prompt candidate, asset type, review state, and snapshot types.
- Add in-memory store and service.
### 2. Generate prompts from ontology evidence
- Select source-backed ontology concept by concept id.
- Generate prompt candidate for diagram, lesson slice, worksheet, or interview
card.
- Reject prompt generation when concept evidence is missing.
### 3. Add HTTP endpoints
- `POST /api/v1/teaching-assets/prompts`
- `GET /api/v1/teaching-assets`
### 4. Add tests and verification
- Test prompt candidates keep concept and source evidence.
- Test model verification guard remains true.
- Test HTTP material-ingest-to-asset-prompt flow.
- Run Go tests, OpenSpec validation, line-count check, and smoke.
## Out of Scope
- Real image generation.
- Slide deck creation.
- Asset publishing.

View File

@@ -0,0 +1,21 @@
# Phase 6 Research: Teaching Assets
## Findings
The product needs lineage before generation. A prompt candidate that carries
concept, source evidence, model key, and review state is already useful because
it can be reviewed before spending image-generation cost or publishing content.
## Recommended Shape
- `internal/teachingassets` owns asset prompt candidates.
- Use ontology snapshot as the source for concept/evidence lookup.
- Generate prompts deterministically for MVP asset types.
- Store `requires_model_id_verification=true` so production image generation is
blocked until the provider model identifier is verified.
## Risks
- Calling image generation before model ID verification would violate PRD.
- Prompt candidates without evidence would weaken provenance.
- Real slide/PPT generation should be a later phase.

View File

@@ -0,0 +1,38 @@
# Phase 6 Summary
**Status:** Complete
**Completed:** 2026-04-26
## Delivered
- Added `internal/teachingassets` for prompt candidates and snapshots.
- Added image model config key `TUTOR_IMAGE_MODEL_KEY`, defaulting to
`gpt-image-v2`.
- Added workflow contract structs for `OntologyGap` and
`TeachingAssetPrompt`.
- Added prompt generation from source-backed ontology concepts.
- Added model-id verification guard on every prompt candidate.
- Added HTTP endpoints:
- `POST /api/v1/teaching-assets/prompts`
- `GET /api/v1/teaching-assets`
- Added service and HTTP tests.
## Verification
```powershell
gofmt -w cmd internal
go test ./...
openspec validate bootstrap-job-tutor-platform --strict
```
Additional smoke check:
- Material ingestion followed by teaching asset prompt generation returned a
source-backed prompt with `requires_model_id_verification=true`.
## Deferred
- Real image generation calls.
- Provider SDK integration.
- Binary asset storage.
- Slide/PPT export.

View File

@@ -0,0 +1,29 @@
# Phase 6 Verification
## Verdict
PASS
## Requirement Coverage
- ASSET-01: PASS. Teaching asset prompt candidates can be generated for
ontology concepts.
- ASSET-02: PASS. Prompt candidates store source concept, evidence, prompt,
model key, and review state.
- ASSET-03: PASS. Prompt candidates carry
`requires_model_id_verification=true`, so production image generation remains
blocked until the provider model identifier is verified.
## Evidence
- `go test ./...` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Live material-to-asset-prompt smoke passed.
- Go source line-count check passed.
## Residual Risk
`gpt-image-v2` is currently treated as the product configuration key, not a
confirmed provider model id. A future production generation phase must verify
the actual OpenAI model identifier against current official docs before making
real calls.

View File

@@ -0,0 +1,38 @@
# Phase 7 Context: Web App Shell and Diagnostic Start
**Status:** Ready for execution
**Started:** 2026-04-26
## Goal
Serve the first browser UI from the Go backend and let a job seeker start
diagnostic practice without API tooling.
## Requirements
- WEB-01: User can open a web app served by the Go service.
- WEB-02: User can create a diagnostic interview session from the web app.
- WEB-03: User can answer a diagnostic question and see rubric feedback.
## UX Direction
Visual thesis: quiet interview coaching workspace, dense but readable, with one
clear green accent for action and readiness.
Content plan:
- left rail: learner setup
- main workspace: active diagnostic questions and answer input
- right context: grading feedback and evidence
Interaction thesis:
- loading states on API actions
- inline error region
- selected question state and answer result refresh
## Out of Scope
- Full progress view.
- Material/ontology workspace.
- Authentication.

View File

@@ -0,0 +1,36 @@
# Phase 7 Plan: Web App Shell and Diagnostic Start
**Status:** Ready for execution
**Phase Goal:** Create a working diagnostic web app shell.
## Tasks
### 1. Serve embedded web app
- Add `internal/webapp`.
- Embed static app assets.
- Register root and asset routes through the existing Go server.
### 2. Build diagnostic UI
- Add setup form for user id, target role, stack, and interview timeline.
- Create diagnostic session through the real API.
- Show returned questions.
### 3. Build answer and grading UI
- Let user select a question.
- Submit answer through the real API.
- Show overall grade, scores, follow-up, and evidence.
### 4. Verify
- Add HTTP tests for root web app and asset serving.
- Run Go tests, OpenSpec validation, line-count check.
- Smoke the rendered app endpoint and diagnostic API flow.
## Out of Scope
- Frontend build tooling.
- Authentication.
- Phase 8 progress panels.

View File

@@ -0,0 +1,27 @@
# Phase 7 Research: Web App Shell and Diagnostic Start
## Findings
The existing Go backend can serve static assets without adding frontend build
tooling. For the first MVP UI, plain HTML/CSS/JavaScript is enough and keeps
the repo dependency-light.
The diagnostic APIs already provide all Phase 7 data:
- `POST /api/v1/diagnostic-sessions`
- `POST /api/v1/diagnostic-sessions/{id}/answers`
- `GET /api/v1/diagnostic-sessions/{id}`
## Recommended Shape
- Add `internal/webapp` with embedded static assets.
- Register web app routes from `httpapi.Handler`.
- Keep frontend files small and focused.
- Use fetch calls directly against existing API routes.
## Risks
- A mock UI would not prove the backend loop. The UI must call real APIs.
- A marketing-style landing page would distract from the core product surface.
- Overbuilding a frontend stack before interaction validation would violate
YAGNI.

View File

@@ -0,0 +1,37 @@
# Phase 7 Summary
**Status:** Complete
**Completed:** 2026-04-26
## Delivered
- Added embedded web app serving from the Go backend.
- Added `GET /` app shell and `/assets/*` static asset handling.
- Built dependency-light HTML/CSS/JavaScript UI for diagnostic practice.
- Added setup form for user id, target role, stack, and timeline.
- Added real API-backed diagnostic session creation.
- Added question selection, answer submission, and rubric feedback rendering.
- Added loading, error, empty, and selected-question states.
- Added web app route and asset tests.
## Verification
```powershell
gofmt -w cmd internal
go test ./...
openspec validate frontend-mvp --strict
openspec validate bootstrap-job-tutor-platform --strict
```
Additional smoke check:
- `GET /` returned the app shell.
- `GET /assets/app.js` returned the browser script.
- Diagnostic session creation and answer grading succeeded through the same
server used by the app.
## Deferred
- Progress panels for memory/readiness/next challenge.
- Material and asset workspace.
- Browser screenshot audit.

View File

@@ -0,0 +1,27 @@
# Phase 7 Verification
## Verdict
PASS
## Requirement Coverage
- WEB-01: PASS. The Go service serves the web app at `/`.
- WEB-02: PASS. The browser app can create diagnostic sessions through the real
backend API.
- WEB-03: PASS. The browser app can submit answers and render typed rubric
feedback, scores, follow-up, and evidence.
## Evidence
- `go test ./...` passed.
- `openspec validate frontend-mvp --strict` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Live root/asset HTTP smoke passed.
- Live diagnostic create/answer smoke passed through the same server.
## Residual Risk
The UI was verified with HTTP/API smoke but not yet with a browser screenshot
audit. Phase 8 should add browser-backed checks once the progress view is
included.

View File

@@ -0,0 +1,34 @@
# Phase 8 Context: Learning Progress View
**Status:** Ready for execution
**Started:** 2026-04-26
## Goal
Show evidence-backed learning progress in the web app after diagnostic
practice.
## Requirements
- WEB-04: User can see learner memory, readiness, and next challenge after
answering.
## Inputs
- Phase 7 web app shell.
- Existing backend endpoints:
- `GET /api/v1/learners/{userID}/memory`
- `GET /api/v1/learners/{userID}/readiness-map`
- `GET /api/v1/learners/{userID}/next-challenge`
## UX Direction
Keep progress in the right-side context column so the answer workspace remains
centered. The user should see the loop close immediately: answer, feedback,
memory, readiness, next challenge.
## Out of Scope
- Full graph visualization.
- Historical readiness timeline.
- Editing learner memory.

View File

@@ -0,0 +1,26 @@
# Phase 8 Plan: Learning Progress View
**Status:** Ready for execution
**Phase Goal:** Close the practice loop with visible learning progress.
## Tasks
### 1. Add progress UI region
- Add learner progress section to the right context pane.
- Include manual refresh affordance.
### 2. Fetch progress after answer
- Fetch learner memory, readiness map, and next challenge after grading.
- Render empty/error states when progress is unavailable.
### 3. Verify
- Add/update web app tests for progress asset content.
- Run Go tests, OpenSpec validation, line-count check, and live smoke.
## Out of Scope
- Charts.
- Historical progress storage.

View File

@@ -0,0 +1,20 @@
# Phase 8 Research: Learning Progress View
## Findings
The backend already provides progress projection after answer submission. The
frontend only needs to fetch and summarize the three endpoints after grading.
Useful MVP display:
- profile target role and stack
- top concept mastery states
- readiness percentage
- next challenge concept, ladder level, and question
## Recommendation
- Refresh progress automatically after successful answer submission.
- Add a manual refresh button for recovery.
- Use empty state before the first answer.
- Keep evidence labels compact so they do not overwhelm the practice surface.

View File

@@ -0,0 +1,33 @@
# Phase 8 Summary
**Status:** Complete
**Completed:** 2026-04-26
## Delivered
- Added learning progress region to the web app right context pane.
- Added manual progress refresh action.
- After answer submission, the app fetches learner memory, readiness map, and
next challenge.
- Rendered readiness percentage, concept mastery states, and recommended next
challenge.
- Added frontend asset test coverage for progress API wiring.
## Verification
```powershell
gofmt -w cmd internal
go test ./...
openspec validate frontend-mvp --strict
```
Additional smoke check:
- Submitted a diagnostic answer, then verified learner memory, readiness, and
next challenge APIs returned progress consumed by the app script.
## Deferred
- Browser screenshot audit.
- Charts and historical progress.
- Editable learner memory.

View File

@@ -0,0 +1,24 @@
# Phase 8 Verification
## Verdict
PASS
## Requirement Coverage
- WEB-04: PASS. The web app can fetch and render learner memory, readiness, and
next challenge after an answer.
## Evidence
- `go test ./...` passed.
- `openspec validate frontend-mvp --strict` passed.
- Static app script includes the readiness API integration.
- Live smoke confirmed memory mastery, readiness percentage, and next challenge
after diagnostic answer submission.
## Residual Risk
The UI is still verified through code and HTTP/API smoke rather than browser
screenshots. Phase 9 should add visual/browser validation after the workspace
surface is complete.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,160 @@
---
milestone: v1
audited: 2026-04-26
status: tech_debt
scores:
requirements: 28/28
phases: 6/6
integration: 6/6
flows: 2/2
gaps:
requirements: []
integration: []
flows: []
tech_debt:
- phase: "001-go-backend-foundation-and-workflow-boundary"
items:
- "Workflow runner is still a deterministic stub; real third-one/agent-farm runtime is deferred."
- "Persistence, auth, frontend, ontology, and asset generation were deferred from the foundation phase."
- phase: "002-diagnostic-interview-loop"
items:
- "Diagnostic sessions are in-memory and lost on process restart."
- "Real third-one grading calls are deferred."
- phase: "003-learner-memory"
items:
- "Learner memory is in-memory."
- "Memory decay, ranking, and repeated-mistake clustering are deferred."
- phase: "004-progression"
items:
- "Readiness history, campaign state, and streak persistence are deferred."
- "Boss unlocks use simple stable-count logic rather than a richer concept cluster graph."
- phase: "005-ontology-materials"
items:
- "Material ingestion accepts JSON text only; multipart/PDF/PPT parsing is deferred."
- "Ontology analyzer is deterministic and shallow."
- "Human review and graph database persistence are deferred."
- phase: "006-teaching-assets"
items:
- "Prompt candidates are generated, but real image generation is deferred."
- "gpt-image-v2 is a product config key; actual provider model id must be verified before production calls."
- "Binary asset storage and slide/PPT export are deferred."
nyquist:
compliant_phases: []
partial_phases: []
missing_phases:
- "001-go-backend-foundation-and-workflow-boundary"
- "002-diagnostic-interview-loop"
- "003-learner-memory"
- "004-progression"
- "005-ontology-materials"
- "006-teaching-assets"
overall: missing
---
# v1 Milestone Audit
## Verdict
TECH_DEBT
All v1 functional requirements are satisfied and all phase verification reports
are PASS. No critical requirement, integration, or end-to-end flow blocker was
found.
The milestone should still be treated as a tech-debt review before archival
because the v1 backend is intentionally MVP-scoped: in-memory stores, stubbed
workflow execution, shallow deterministic ontology extraction, and prompt-only
teaching assets.
## Scope
| Phase | Name | Verification |
|-------|------|--------------|
| 1 | Go backend foundation and workflow boundary | PASS |
| 2 | Diagnostic interview loop | PASS |
| 3 | Learner memory | PASS |
| 4 | Progression | PASS |
| 5 | Ontology and learning materials | PASS |
| 6 | Teaching assets | PASS |
## Requirements Coverage
| Requirement Group | Count | Status | Evidence |
|-------------------|-------|--------|----------|
| BACK-01..BACK-05 | 5/5 | Satisfied | Phase 1 verification |
| INT-01..INT-06 | 6/6 | Satisfied | Phase 2 verification |
| MEM-01..MEM-05 | 5/5 | Satisfied | Phase 3 verification |
| PROG-01..PROG-05 | 5/5 | Satisfied | Phase 4 verification |
| ONTO-01..ONTO-04 | 4/4 | Satisfied | Phase 5 verification |
| ASSET-01..ASSET-03 | 3/3 | Satisfied | Phase 6 verification |
Total: 28/28 v1 requirements satisfied.
## Cross-Phase Integration
| Flow | Result | Evidence |
|------|--------|----------|
| Diagnostic to learner memory to progression | PASS | Live smoke created a diagnostic session, submitted an answer, read learner memory, readiness map, and next challenge. |
| Material ingestion to ontology to teaching asset prompt | PASS | Live smoke ingested material, read ontology candidates, and generated a source-backed teaching asset prompt. |
| App wiring | PASS | `internal/app/server.go` wires workflow runner, interview service, learner memory, progression, ontology, and teaching assets into one HTTP handler. |
| Typed workflow boundary | PASS | State-changing workflow outputs use Go structs and evidence references; handlers do not parse shell output. |
| Source lineage | PASS | Ontology candidates and teaching asset prompts preserve source evidence and candidate review state. |
| Image model guard | PASS | Teaching asset prompts store `model_key` and keep `requires_model_id_verification=true`. |
## End-to-End Smoke Evidence
Latest audit smoke result:
```json
{
"SessionID": "diag-1",
"AnswerGrade": "solid",
"MemoryMastery": 1,
"Readiness": 75,
"ChallengeConcept": "http-idempotency",
"MaterialID": "material-1",
"OntologyConcepts": 4,
"OntologyEdges": 3,
"AssetPromptID": "asset-prompt-1",
"AssetModelKey": "gpt-image-v2",
"AssetRequiresVerification": true
}
```
## Validation Evidence
- `go test ./...` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Go source line-count check passed; no manually authored Go file exceeds 600
lines.
- Git worktree was clean before the audit began.
## Requirement Cross-Reference Notes
The requirement checklist and phase verification reports agree: every v1
requirement is marked complete and has a matching PASS entry in the relevant
phase verification file.
Phase summary files do not include YAML frontmatter with
`requirements-completed`. This is not a product blocker because each summary has
a matching verification report, but adding standardized summary frontmatter
would make future automated audits cleaner.
## Nyquist Discovery
No `*-VALIDATION.md` files were found for phases 1 through 6. This audit treats
that as validation-process debt rather than a v1 product blocker because each
phase already has a PASS verification report plus current tests and smoke
coverage.
## Recommended Next Milestone
Choose one of these as the next milestone:
- Frontend MVP: build the web experience on top of the completed backend flow.
- Persistence: replace in-memory stores with durable storage and migration
boundaries.
- Real workflow runtime: connect the typed runner boundary to third-one and
internalized agent-farm workflow execution.
- Document parser integration: add PDF/PPT/slide/document ingestion before
richer ontology work.

View File

@@ -0,0 +1,113 @@
---
milestone: v2
name: Frontend MVP
audited: 2026-04-26
status: tech_debt
scores:
requirements: 8/8
phases: 3/3
integration: 4/4
flows: 3/3
gaps:
requirements: []
integration: []
flows: []
tech_debt:
- phase: "007-web-app-shell-diagnostic-start"
items:
- "UI was initially verified with HTTP/API smoke rather than browser screenshots."
- phase: "008-learning-progress-view"
items:
- "Progress view has no charts or historical readiness timeline."
- phase: "009-material-asset-workspace"
items:
- "Chrome DevTools MCP timed out during local browser screenshot attempt."
- "Material and asset workspace is still prompt-only and has no real image generation."
nyquist:
compliant_phases: []
partial_phases: []
missing_phases:
- "007-web-app-shell-diagnostic-start"
- "008-learning-progress-view"
- "009-material-asset-workspace"
overall: missing
---
# v2 Frontend MVP Audit
## Verdict
TECH_DEBT
All v2 Frontend MVP requirements are satisfied. The Go service now serves a
working browser UI for diagnostic practice, progress review, material
ingestion, ontology inspection, and teaching asset prompt generation.
The milestone remains `tech_debt` rather than `passed` because visual browser
screenshot validation could not be completed: Chrome DevTools MCP timed out
while opening the local page. HTTP/API smoke, static asset inspection, Go tests,
and OpenSpec validation all passed.
## Requirements Coverage
| Requirement | Status | Evidence |
|-------------|--------|----------|
| WEB-01 | Satisfied | Web app served at `/`. |
| WEB-02 | Satisfied | Diagnostic session creation wired from app script to API. |
| WEB-03 | Satisfied | Answer submission and rubric feedback rendering implemented. |
| WEB-04 | Satisfied | Learner memory, readiness, and next challenge APIs wired and rendered. |
| WEB-05 | Satisfied | Material ingestion UI calls `POST /api/v1/materials`. |
| WEB-06 | Satisfied | Ontology concept, edge, and gap summary rendering implemented. |
| WEB-07 | Satisfied | Teaching asset prompt generation UI implemented. |
| WEB-08 | Satisfied | Loading, empty, and error states exist across MVP flows. |
Total: 8/8 v2 requirements satisfied.
## Cross-Phase Integration
| Integration | Result |
|-------------|--------|
| Go backend static serving to web app | PASS |
| Diagnostic UI to backend diagnostic APIs | PASS |
| Answer grading to learner progress UI | PASS |
| Material ingestion to ontology to teaching asset prompt UI | PASS |
## E2E Smoke Evidence
```json
{
"HtmlStatus": 200,
"HtmlHasWorkspace": true,
"JsHasAllApis": true,
"SessionID": "diag-1",
"Readiness": 75,
"OntologyConcepts": 4,
"AssetPromptID": "asset-prompt-1",
"VerifyGuard": true
}
```
## Validation Evidence
- `go test ./...` passed.
- `openspec validate frontend-mvp --strict` passed.
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
- Source line-count check passed.
## Recommended Next Milestone
Recommended next milestone: Persistence and Runtime Hardening.
Rationale:
- The browser MVP now proves the user journey.
- The biggest product risk is data loss from in-memory stores.
- Real `third-one` / internalized `agent-farm-go` runtime integration should be
added after durable state boundaries are in place.
Candidate phases:
1. Durable persistence for sessions, learner memory, ontology, and asset
prompts.
2. Runtime configuration and workflow package execution.
3. Browser visual regression and UI hardening.

View File

@@ -7,6 +7,9 @@ import (
"tutor/internal/httpapi" "tutor/internal/httpapi"
"tutor/internal/interview" "tutor/internal/interview"
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -14,8 +17,11 @@ func NewServer(cfg config.Config) *http.Server {
runner := workflows.NewStubRunner() runner := workflows.NewStubRunner()
store := interview.NewMemoryStore() store := interview.NewMemoryStore()
memory := learnermemory.NewService(learnermemory.NewMemoryStore()) memory := learnermemory.NewService(learnermemory.NewMemoryStore())
progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey)
service := interview.NewService(store, runner, memory) service := interview.NewService(store, runner, memory)
handler := httpapi.NewHandler(cfg, service, memory) handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
return &http.Server{ return &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,

View File

@@ -6,6 +6,7 @@ const (
defaultHTTPAddr = ":8080" defaultHTTPAddr = ":8080"
defaultEnvironment = "development" defaultEnvironment = "development"
defaultModelKey = "deepseek-v4-flash" defaultModelKey = "deepseek-v4-flash"
defaultImageModelKey = "gpt-image-v2"
defaultThirdOneBin = "thirdone" defaultThirdOneBin = "thirdone"
defaultWorkflowRuntime = "" defaultWorkflowRuntime = ""
) )
@@ -15,6 +16,7 @@ type Config struct {
Environment string Environment string
WorkflowRuntime string WorkflowRuntime string
ModelKey string ModelKey string
ImageModelKey string
ThirdOneBin string ThirdOneBin string
} }
@@ -24,6 +26,7 @@ func LoadFromEnv() Config {
Environment: envOrDefault("TUTOR_ENV", defaultEnvironment), Environment: envOrDefault("TUTOR_ENV", defaultEnvironment),
WorkflowRuntime: envOrDefault("TUTOR_WORKFLOW_RUNTIME", defaultWorkflowRuntime), WorkflowRuntime: envOrDefault("TUTOR_WORKFLOW_RUNTIME", defaultWorkflowRuntime),
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey), ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin), ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
} }
} }

View File

@@ -7,6 +7,7 @@ func TestLoadFromEnvDefaults(t *testing.T) {
t.Setenv("TUTOR_ENV", "") t.Setenv("TUTOR_ENV", "")
t.Setenv("TUTOR_WORKFLOW_RUNTIME", "") t.Setenv("TUTOR_WORKFLOW_RUNTIME", "")
t.Setenv("TUTOR_MODEL_KEY", "") t.Setenv("TUTOR_MODEL_KEY", "")
t.Setenv("TUTOR_IMAGE_MODEL_KEY", "")
t.Setenv("THIRDONE_BIN", "") t.Setenv("THIRDONE_BIN", "")
cfg := LoadFromEnv() cfg := LoadFromEnv()
@@ -20,6 +21,9 @@ func TestLoadFromEnvDefaults(t *testing.T) {
if cfg.ModelKey != defaultModelKey { if cfg.ModelKey != defaultModelKey {
t.Fatalf("ModelKey = %q, want %q", cfg.ModelKey, defaultModelKey) t.Fatalf("ModelKey = %q, want %q", cfg.ModelKey, defaultModelKey)
} }
if cfg.ImageModelKey != defaultImageModelKey {
t.Fatalf("ImageModelKey = %q, want %q", cfg.ImageModelKey, defaultImageModelKey)
}
if cfg.ThirdOneBin != defaultThirdOneBin { if cfg.ThirdOneBin != defaultThirdOneBin {
t.Fatalf("ThirdOneBin = %q, want %q", cfg.ThirdOneBin, defaultThirdOneBin) t.Fatalf("ThirdOneBin = %q, want %q", cfg.ThirdOneBin, defaultThirdOneBin)
} }
@@ -30,6 +34,7 @@ func TestLoadFromEnvOverrides(t *testing.T) {
t.Setenv("TUTOR_ENV", "test") t.Setenv("TUTOR_ENV", "test")
t.Setenv("TUTOR_WORKFLOW_RUNTIME", "runtime.yaml") t.Setenv("TUTOR_WORKFLOW_RUNTIME", "runtime.yaml")
t.Setenv("TUTOR_MODEL_KEY", "other-model") t.Setenv("TUTOR_MODEL_KEY", "other-model")
t.Setenv("TUTOR_IMAGE_MODEL_KEY", "other-image-model")
t.Setenv("THIRDONE_BIN", "C:/bin/thirdone.exe") t.Setenv("THIRDONE_BIN", "C:/bin/thirdone.exe")
cfg := LoadFromEnv() cfg := LoadFromEnv()
@@ -46,6 +51,9 @@ func TestLoadFromEnvOverrides(t *testing.T) {
if cfg.ModelKey != "other-model" { if cfg.ModelKey != "other-model" {
t.Fatalf("ModelKey = %q", cfg.ModelKey) t.Fatalf("ModelKey = %q", cfg.ModelKey)
} }
if cfg.ImageModelKey != "other-image-model" {
t.Fatalf("ImageModelKey = %q", cfg.ImageModelKey)
}
if cfg.ThirdOneBin != "C:/bin/thirdone.exe" { if cfg.ThirdOneBin != "C:/bin/thirdone.exe" {
t.Fatalf("ThirdOneBin = %q", cfg.ThirdOneBin) t.Fatalf("ThirdOneBin = %q", cfg.ThirdOneBin)
} }

View File

@@ -10,13 +10,19 @@ import (
"tutor/internal/config" "tutor/internal/config"
"tutor/internal/interview" "tutor/internal/interview"
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
func TestDiagnosticHTTPFlow(t *testing.T) { func TestDiagnosticHTTPFlow(t *testing.T) {
memory := learnermemory.NewService(learnermemory.NewMemoryStore()) memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, "gpt-image-v2")
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory, progress, onto, assets)
routes := handler.Routes() routes := handler.Routes()
createBody := bytes.NewBufferString(`{ createBody := bytes.NewBufferString(`{
@@ -93,4 +99,20 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
if len(snapshot.Mastery) == 0 { if len(snapshot.Mastery) == 0 {
t.Fatal("expected mastery entries") t.Fatal("expected mastery entries")
} }
readinessReq := httptest.NewRequest(http.MethodGet, "/api/v1/learners/user-1/readiness-map", nil)
readinessRec := httptest.NewRecorder()
routes.ServeHTTP(readinessRec, readinessReq)
if readinessRec.Code != http.StatusOK {
t.Fatalf("readiness status = %d, body = %s", readinessRec.Code, readinessRec.Body.String())
}
challengeReq := httptest.NewRequest(http.MethodGet, "/api/v1/learners/user-1/next-challenge", nil)
challengeRec := httptest.NewRecorder()
routes.ServeHTTP(challengeRec, challengeReq)
if challengeRec.Code != http.StatusOK {
t.Fatalf("challenge status = %d, body = %s", challengeRec.Code, challengeRec.Body.String())
}
} }

View File

@@ -7,19 +7,36 @@ import (
"tutor/internal/config" "tutor/internal/config"
"tutor/internal/interview" "tutor/internal/interview"
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/webapp"
) )
type Handler struct { type Handler struct {
cfg config.Config cfg config.Config
diagnostic *interview.Service diagnostic *interview.Service
memory *learnermemory.Service memory *learnermemory.Service
progress *progression.Service
ontology *ontology.Service
assets *teachingassets.Service
} }
func NewHandler(cfg config.Config, diagnostic *interview.Service, memory *learnermemory.Service) Handler { func NewHandler(
cfg config.Config,
diagnostic *interview.Service,
memory *learnermemory.Service,
progress *progression.Service,
ontology *ontology.Service,
assets *teachingassets.Service,
) Handler {
return Handler{ return Handler{
cfg: cfg, cfg: cfg,
diagnostic: diagnostic, diagnostic: diagnostic,
memory: memory, memory: memory,
progress: progress,
ontology: ontology,
assets: assets,
} }
} }
@@ -30,6 +47,13 @@ func (h Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession) mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession)
mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer) mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer)
mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory) mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory)
mux.HandleFunc("GET /api/v1/learners/{userID}/readiness-map", h.getReadinessMap)
mux.HandleFunc("GET /api/v1/learners/{userID}/next-challenge", h.getNextChallenge)
mux.HandleFunc("POST /api/v1/materials", h.ingestMaterial)
mux.HandleFunc("GET /api/v1/ontology", h.getOntology)
mux.HandleFunc("POST /api/v1/teaching-assets/prompts", h.generateTeachingAssetPrompt)
mux.HandleFunc("GET /api/v1/teaching-assets", h.getTeachingAssets)
mux.Handle("GET /", webapp.Handler())
return mux return mux
} }

View File

@@ -9,6 +9,9 @@ import (
"tutor/internal/config" "tutor/internal/config"
"tutor/internal/interview" "tutor/internal/interview"
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -19,7 +22,10 @@ func TestHealth(t *testing.T) {
} }
memory := learnermemory.NewService(learnermemory.NewMemoryStore()) memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
handler := NewHandler(cfg, service, memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey)
handler := NewHandler(cfg, service, memory, progress, onto, assets)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -44,3 +50,28 @@ func TestHealth(t *testing.T) {
t.Fatalf("body.ModelKey = %q", body.ModelKey) t.Fatalf("body.ModelKey = %q", body.ModelKey)
} }
} }
func TestWebAppRoute(t *testing.T) {
cfg := config.Config{
Environment: "test",
ModelKey: "deepseek-v4-flash",
}
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey)
handler := NewHandler(cfg, service, memory, progress, onto, assets)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
}
}

View File

@@ -0,0 +1,48 @@
package httpapi
import (
"encoding/json"
"net/http"
"tutor/internal/ontology"
)
type ingestMaterialRequest struct {
Title string `json:"title"`
SourceType string `json:"source_type"`
Body string `json:"body"`
}
func (h Handler) ingestMaterial(w http.ResponseWriter, r *http.Request) {
if h.ontology == nil {
writeError(w, http.StatusNotFound, "ontology not configured")
return
}
var req ingestMaterialRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
result, err := h.ontology.Ingest(ontology.IngestInput{
Title: req.Title,
SourceType: req.SourceType,
Body: req.Body,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
func (h Handler) getOntology(w http.ResponseWriter, _ *http.Request) {
if h.ontology == nil {
writeError(w, http.StatusNotFound, "ontology not configured")
return
}
writeJSON(w, http.StatusOK, h.ontology.Snapshot())
}

View File

@@ -0,0 +1,56 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"tutor/internal/config"
"tutor/internal/interview"
"tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows"
)
func TestOntologyHTTPFlow(t *testing.T) {
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, "gpt-image-v2")
handler := NewHandler(config.Config{Environment: "test"}, service, memory, progress, onto, assets)
routes := handler.Routes()
body := bytes.NewBufferString(`{
"title":"Backend interview notes",
"source_type":"markdown",
"body":"Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs."
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/materials", body)
rec := httptest.NewRecorder()
routes.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("ingest status = %d, body = %s", rec.Code, rec.Body.String())
}
var result ontology.IngestResult
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatalf("decode ingest response: %v", err)
}
if len(result.Snapshot.Concepts) == 0 {
t.Fatal("expected ontology concepts")
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/ontology", nil)
getRec := httptest.NewRecorder()
routes.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("ontology status = %d, body = %s", getRec.Code, getRec.Body.String())
}
}

View File

@@ -0,0 +1,46 @@
package httpapi
import (
"errors"
"net/http"
"tutor/internal/learnermemory"
)
func (h Handler) getReadinessMap(w http.ResponseWriter, r *http.Request) {
if h.progress == nil {
writeError(w, http.StatusNotFound, "progression not configured")
return
}
readiness, err := h.progress.ReadinessMap(r.PathValue("userID"))
if errors.Is(err, learnermemory.ErrProfileNotFound) {
writeError(w, http.StatusNotFound, "learner memory not found")
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, readiness)
}
func (h Handler) getNextChallenge(w http.ResponseWriter, r *http.Request) {
if h.progress == nil {
writeError(w, http.StatusNotFound, "progression not configured")
return
}
challenge, err := h.progress.NextChallenge(r.PathValue("userID"))
if errors.Is(err, learnermemory.ErrProfileNotFound) {
writeError(w, http.StatusNotFound, "learner memory not found")
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, challenge)
}

View File

@@ -0,0 +1,47 @@
package httpapi
import (
"encoding/json"
"net/http"
"tutor/internal/teachingassets"
"tutor/internal/workflows"
)
type generateTeachingAssetPromptRequest struct {
ConceptID string `json:"concept_id"`
AssetType workflows.AssetType `json:"asset_type"`
}
func (h Handler) generateTeachingAssetPrompt(w http.ResponseWriter, r *http.Request) {
if h.assets == nil {
writeError(w, http.StatusNotFound, "teaching assets not configured")
return
}
var req generateTeachingAssetPromptRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
prompt, err := h.assets.GeneratePrompt(teachingassets.GenerateInput{
ConceptID: req.ConceptID,
AssetType: req.AssetType,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, prompt)
}
func (h Handler) getTeachingAssets(w http.ResponseWriter, _ *http.Request) {
if h.assets == nil {
writeError(w, http.StatusNotFound, "teaching assets not configured")
return
}
writeJSON(w, http.StatusOK, h.assets.Snapshot())
}

View File

@@ -0,0 +1,64 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"tutor/internal/config"
"tutor/internal/interview"
"tutor/internal/learnermemory"
"tutor/internal/ontology"
"tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows"
)
func TestTeachingAssetsHTTPFlow(t *testing.T) {
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore())
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, "gpt-image-v2")
handler := NewHandler(config.Config{Environment: "test"}, service, memory, progress, onto, assets)
routes := handler.Routes()
ingestBody := bytes.NewBufferString(`{
"title":"Backend notes",
"body":"Idempotent API retries need transactions."
}`)
ingestReq := httptest.NewRequest(http.MethodPost, "/api/v1/materials", ingestBody)
ingestRec := httptest.NewRecorder()
routes.ServeHTTP(ingestRec, ingestReq)
if ingestRec.Code != http.StatusCreated {
t.Fatalf("ingest status = %d, body = %s", ingestRec.Code, ingestRec.Body.String())
}
promptBody := bytes.NewBufferString(`{
"concept_id":"http-idempotency",
"asset_type":"diagram"
}`)
promptReq := httptest.NewRequest(http.MethodPost, "/api/v1/teaching-assets/prompts", promptBody)
promptRec := httptest.NewRecorder()
routes.ServeHTTP(promptRec, promptReq)
if promptRec.Code != http.StatusCreated {
t.Fatalf("prompt status = %d, body = %s", promptRec.Code, promptRec.Body.String())
}
var prompt teachingassets.PromptCandidate
if err := json.NewDecoder(promptRec.Body).Decode(&prompt); err != nil {
t.Fatalf("decode prompt response: %v", err)
}
if !prompt.RequiresModelIDVerification {
t.Fatal("expected verification guard")
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/teaching-assets", nil)
getRec := httptest.NewRecorder()
routes.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("assets status = %d, body = %s", getRec.Code, getRec.Body.String())
}
}

View File

@@ -0,0 +1,38 @@
package ontology
import "tutor/internal/workflows"
var knownConcepts = []knownConcept{
{
Ref: workflows.ConceptRef{ID: "http-idempotency", Label: "HTTP idempotency", Track: "backend-developer"},
Keywords: []string{"idempotent", "idempotency", "retry", "retries"},
},
{
Ref: workflows.ConceptRef{ID: "database-indexes", Label: "Database indexes", Track: "backend-developer"},
Keywords: []string{"index", "indexes", "database index", "query plan"},
},
{
Ref: workflows.ConceptRef{ID: "cache-invalidation", Label: "Cache invalidation", Track: "backend-developer"},
Keywords: []string{"cache", "invalidation", "ttl"},
},
{
Ref: workflows.ConceptRef{ID: "transactions", Label: "Transactions", Track: "backend-developer"},
Keywords: []string{"transaction", "transactions", "atomic", "rollback"},
},
}
var prerequisiteRules = []prerequisiteRule{
{FromID: "http-idempotency", ToID: "transactions"},
{FromID: "transactions", ToID: "cache-invalidation"},
{FromID: "database-indexes", ToID: "cache-invalidation"},
}
type knownConcept struct {
Ref workflows.ConceptRef
Keywords []string
}
type prerequisiteRule struct {
FromID string
ToID string
}

View File

@@ -0,0 +1,192 @@
package ontology
import (
"errors"
"fmt"
"sort"
"strings"
"sync/atomic"
"time"
"tutor/internal/workflows"
)
type Service struct {
store Store
ids atomic.Uint64
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) Ingest(input IngestInput) (IngestResult, error) {
if strings.TrimSpace(input.Title) == "" {
return IngestResult{}, errors.New("title is required")
}
if strings.TrimSpace(input.Body) == "" {
return IngestResult{}, errors.New("body is required")
}
now := time.Now().UTC()
material := Material{
ID: s.nextID("material"),
Title: input.Title,
SourceType: sourceTypeOrDefault(input.SourceType),
Body: input.Body,
CreatedAt: now,
}
concepts := s.extractConcepts(material, now)
edges := s.extractEdges(concepts, now)
gaps := s.detectGaps(concepts, edges, now)
if err := s.store.Save(material, concepts, edges, gaps); err != nil {
return IngestResult{}, err
}
return IngestResult{
Material: material,
Snapshot: s.store.Snapshot(),
}, nil
}
func (s *Service) Snapshot() Snapshot {
return s.store.Snapshot()
}
func (s *Service) extractConcepts(material Material, now time.Time) []ConceptCandidate {
body := strings.ToLower(material.Body)
concepts := []ConceptCandidate{}
for _, known := range knownConcepts {
quote, ok := firstKeywordQuote(body, material.Body, known.Keywords)
if !ok {
continue
}
concepts = append(concepts, ConceptCandidate{
ID: s.nextID("concept"),
Concept: known.Ref,
Summary: "Source material mentions " + known.Ref.Label + ".",
Evidence: []workflows.EvidenceRef{{
Kind: workflows.EvidenceSource,
ID: material.ID,
Quote: quote,
Confidence: 0.72,
}},
ReviewState: ReviewCandidate,
CreatedAt: now,
})
}
sort.Slice(concepts, func(i, j int) bool {
return concepts[i].Concept.ID < concepts[j].Concept.ID
})
return concepts
}
func (s *Service) extractEdges(concepts []ConceptCandidate, now time.Time) []EdgeCandidate {
byID := make(map[string]ConceptCandidate, len(concepts))
for _, concept := range concepts {
byID[concept.Concept.ID] = concept
}
edges := []EdgeCandidate{}
for _, rule := range prerequisiteRules {
from, fromOK := byID[rule.FromID]
to, toOK := byID[rule.ToID]
if !fromOK || !toOK {
continue
}
edges = append(edges, EdgeCandidate{
ID: s.nextID("edge"),
From: from.Concept,
To: to.Concept,
Kind: EdgePrerequisite,
Evidence: append([]workflows.EvidenceRef(nil), from.Evidence...),
ReviewState: ReviewCandidate,
CreatedAt: now,
})
}
return edges
}
func (s *Service) detectGaps(
concepts []ConceptCandidate,
edges []EdgeCandidate,
now time.Time,
) []Gap {
gaps := []Gap{}
byID := make(map[string]ConceptCandidate, len(concepts))
for _, concept := range concepts {
byID[concept.Concept.ID] = concept
if len(concept.Evidence) == 1 && len(strings.Fields(concept.Evidence[0].Quote)) < 6 {
gaps = append(gaps, Gap{
ID: s.nextID("gap"),
Concept: concept.Concept,
GapType: GapWeakEvidence,
Reason: "Concept is mentioned, but source support is thin.",
SupportingEvidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
ProposedAction: ActionRequestSource,
ReviewState: ReviewCandidate,
CreatedAt: now,
})
}
}
for _, rule := range prerequisiteRules {
to, toOK := byID[rule.ToID]
if !toOK {
continue
}
if _, fromOK := byID[rule.FromID]; fromOK {
continue
}
gaps = append(gaps, Gap{
ID: s.nextID("gap"),
Concept: to.Concept,
GapType: GapMissingPrerequisite,
Reason: "Prerequisite concept " + rule.FromID + " is missing from the material.",
SupportingEvidence: append([]workflows.EvidenceRef(nil), to.Evidence...),
ProposedAction: ActionGenerateCandidate,
ReviewState: ReviewCandidate,
CreatedAt: now,
})
}
if len(edges) == 0 && len(concepts) > 1 {
first := concepts[0]
gaps = append(gaps, Gap{
ID: s.nextID("gap"),
Concept: first.Concept,
GapType: GapMissingPrerequisite,
Reason: "Concept relationship is inferred as incomplete and needs review.",
SupportingEvidence: append([]workflows.EvidenceRef(nil), first.Evidence...),
ProposedAction: ActionHumanReview,
ReviewState: ReviewCandidate,
CreatedAt: now,
})
}
return gaps
}
func firstKeywordQuote(lowerBody string, originalBody string, keywords []string) (string, bool) {
for _, keyword := range keywords {
index := strings.Index(lowerBody, strings.ToLower(keyword))
if index < 0 {
continue
}
start := max(0, index-40)
end := min(len(originalBody), index+len(keyword)+80)
return strings.TrimSpace(originalBody[start:end]), true
}
return "", false
}
func sourceTypeOrDefault(sourceType string) string {
if strings.TrimSpace(sourceType) == "" {
return "text"
}
return sourceType
}
func (s *Service) nextID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, s.ids.Add(1))
}

View File

@@ -0,0 +1,53 @@
package ontology
import "testing"
func TestIngestCreatesSourceBackedCandidates(t *testing.T) {
service := NewService(NewMemoryStore())
result, err := service.Ingest(IngestInput{
Title: "Backend interview notes",
SourceType: "markdown",
Body: "Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs.",
})
if err != nil {
t.Fatalf("Ingest error: %v", err)
}
if result.Material.ID == "" {
t.Fatal("expected material id")
}
if len(result.Snapshot.Concepts) == 0 {
t.Fatal("expected concept candidates")
}
for _, concept := range result.Snapshot.Concepts {
if concept.ReviewState != ReviewCandidate {
t.Fatalf("review state = %q", concept.ReviewState)
}
if len(concept.Evidence) == 0 {
t.Fatal("expected concept evidence")
}
}
if len(result.Snapshot.Edges) == 0 {
t.Fatal("expected prerequisite edge candidates")
}
}
func TestIngestMarksGapsAsCandidates(t *testing.T) {
service := NewService(NewMemoryStore())
result, err := service.Ingest(IngestInput{
Title: "Cache note",
Body: "Cache invalidation is hard.",
})
if err != nil {
t.Fatalf("Ingest error: %v", err)
}
if len(result.Snapshot.Gaps) == 0 {
t.Fatal("expected gaps")
}
for _, gap := range result.Snapshot.Gaps {
if gap.ReviewState != ReviewCandidate {
t.Fatalf("gap review state = %q", gap.ReviewState)
}
}
}

View File

@@ -0,0 +1,87 @@
package ontology
import "sync"
import "tutor/internal/workflows"
type Store interface {
Save(Material, []ConceptCandidate, []EdgeCandidate, []Gap) error
Snapshot() Snapshot
}
type MemoryStore struct {
mu sync.RWMutex
materials []Material
concepts []ConceptCandidate
edges []EdgeCandidate
gaps []Gap
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (s *MemoryStore) Save(
material Material,
concepts []ConceptCandidate,
edges []EdgeCandidate,
gaps []Gap,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.materials = append(s.materials, cloneMaterial(material))
s.concepts = append(s.concepts, cloneConcepts(concepts)...)
s.edges = append(s.edges, cloneEdges(edges)...)
s.gaps = append(s.gaps, cloneGaps(gaps)...)
return nil
}
func (s *MemoryStore) Snapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return Snapshot{
Materials: cloneMaterials(s.materials),
Concepts: cloneConcepts(s.concepts),
Edges: cloneEdges(s.edges),
Gaps: cloneGaps(s.gaps),
}
}
func cloneMaterial(material Material) Material {
return material
}
func cloneMaterials(items []Material) []Material {
cloned := make([]Material, len(items))
copy(cloned, items)
return cloned
}
func cloneConcepts(items []ConceptCandidate) []ConceptCandidate {
cloned := make([]ConceptCandidate, len(items))
for i, item := range items {
cloned[i] = item
cloned[i].Evidence = append([]workflows.EvidenceRef(nil), item.Evidence...)
}
return cloned
}
func cloneEdges(items []EdgeCandidate) []EdgeCandidate {
cloned := make([]EdgeCandidate, len(items))
for i, item := range items {
cloned[i] = item
cloned[i].Evidence = append([]workflows.EvidenceRef(nil), item.Evidence...)
}
return cloned
}
func cloneGaps(items []Gap) []Gap {
cloned := make([]Gap, len(items))
for i, item := range items {
cloned[i] = item
cloned[i].SupportingEvidence = append([]workflows.EvidenceRef(nil), item.SupportingEvidence...)
}
return cloned
}

View File

@@ -0,0 +1,91 @@
package ontology
import (
"time"
"tutor/internal/workflows"
)
type ReviewState string
const (
ReviewCandidate ReviewState = "candidate"
ReviewReviewed ReviewState = "reviewed"
)
type Material struct {
ID string `json:"id"`
Title string `json:"title"`
SourceType string `json:"source_type"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ConceptCandidate struct {
ID string `json:"id"`
Concept workflows.ConceptRef `json:"concept"`
Summary string `json:"summary"`
Evidence []workflows.EvidenceRef `json:"evidence"`
ReviewState ReviewState `json:"review_state"`
CreatedAt time.Time `json:"created_at"`
}
type EdgeCandidate struct {
ID string `json:"id"`
From workflows.ConceptRef `json:"from"`
To workflows.ConceptRef `json:"to"`
Kind EdgeKind `json:"kind"`
Evidence []workflows.EvidenceRef `json:"evidence"`
ReviewState ReviewState `json:"review_state"`
CreatedAt time.Time `json:"created_at"`
}
type EdgeKind string
const (
EdgePrerequisite EdgeKind = "prerequisite"
)
type Gap struct {
ID string `json:"id"`
Concept workflows.ConceptRef `json:"concept"`
GapType GapType `json:"gap_type"`
Reason string `json:"reason"`
SupportingEvidence []workflows.EvidenceRef `json:"supporting_evidence"`
ProposedAction ProposedAction `json:"proposed_action"`
ReviewState ReviewState `json:"review_state"`
CreatedAt time.Time `json:"created_at"`
}
type GapType string
const (
GapMissingPrerequisite GapType = "missing_prerequisite"
GapWeakEvidence GapType = "weak_evidence"
)
type ProposedAction string
const (
ActionGenerateCandidate ProposedAction = "generate_candidate"
ActionRequestSource ProposedAction = "request_source"
ActionHumanReview ProposedAction = "human_review"
)
type IngestInput struct {
Title string
SourceType string
Body string
}
type IngestResult struct {
Material Material `json:"material"`
Snapshot Snapshot `json:"snapshot"`
}
type Snapshot struct {
Materials []Material `json:"materials"`
Concepts []ConceptCandidate `json:"concepts"`
Edges []EdgeCandidate `json:"edges"`
Gaps []Gap `json:"gaps"`
}

View File

@@ -0,0 +1,207 @@
package progression
import (
"errors"
"sort"
"strings"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
type Service struct {
memory *learnermemory.Service
}
func NewService(memory *learnermemory.Service) *Service {
return &Service{memory: memory}
}
func (s *Service) ReadinessMap(userID string) (ReadinessMap, error) {
snapshot, err := s.snapshot(userID)
if err != nil {
return ReadinessMap{}, err
}
concepts := make([]ConceptProgress, 0, len(snapshot.Mastery))
for _, mastery := range snapshot.Mastery {
concepts = append(concepts, ConceptProgress{
Concept: mastery.Concept,
State: mastery.State,
LadderLevel: ladderForState(mastery.State),
NextAction: actionForState(mastery.State),
Evidence: append([]workflows.EvidenceRef(nil), mastery.Evidence...),
})
}
sort.Slice(concepts, func(i, j int) bool {
return concepts[i].Concept.ID < concepts[j].Concept.ID
})
readiness := ReadinessMap{
UserID: snapshot.Profile.UserID,
Track: trackFromConcepts(concepts),
ReadinessPercentage: readinessPercentage(concepts),
Concepts: concepts,
Rewards: rewardsFromConcepts(concepts),
Unlocks: unlocksFromConcepts(concepts),
}
return readiness, nil
}
func (s *Service) NextChallenge(userID string) (workflows.NextChallenge, error) {
readiness, err := s.ReadinessMap(userID)
if err != nil {
return workflows.NextChallenge{}, err
}
if len(readiness.Concepts) == 0 {
return workflows.NextChallenge{}, errors.New("no learner memory concepts available")
}
target := readiness.Concepts[0]
for _, concept := range readiness.Concepts[1:] {
if readinessScore(concept.State) < readinessScore(target.State) {
target = concept
}
}
return workflows.NextChallenge{
UserID: readiness.UserID,
Track: readiness.Track,
Concept: target.Concept,
LadderLevel: target.LadderLevel,
Question: challengeQuestion(target),
Rationale: "Selected from the weakest evidenced learner-memory concept.",
DifficultyAction: workflowDifficulty(target.NextAction),
Evidence: append([]workflows.EvidenceRef(nil), target.Evidence...),
}, nil
}
func (s *Service) snapshot(userID string) (learnermemory.Snapshot, error) {
if s.memory == nil {
return learnermemory.Snapshot{}, errors.New("learner memory not configured")
}
if strings.TrimSpace(userID) == "" {
return learnermemory.Snapshot{}, errors.New("user_id is required")
}
return s.memory.Snapshot(userID)
}
func ladderForState(state workflows.ReadinessState) workflows.LadderLevel {
switch state {
case workflows.ReadinessFragile:
return workflows.LadderDefine
case workflows.ReadinessImproving:
return workflows.LadderTradeoffs
case workflows.ReadinessInterviewReady:
return workflows.LadderDesignConstraints
case workflows.ReadinessStrongSignal:
return workflows.LadderInterviewPressure
default:
return workflows.LadderDefine
}
}
func actionForState(state workflows.ReadinessState) DifficultyAction {
switch state {
case workflows.ReadinessFragile:
return ActionRecover
case workflows.ReadinessImproving:
return ActionHold
case workflows.ReadinessInterviewReady, workflows.ReadinessStrongSignal:
return ActionRaise
default:
return ActionLower
}
}
func workflowDifficulty(action DifficultyAction) workflows.DifficultyAction {
switch action {
case ActionRecover:
return workflows.DifficultyRecover
case ActionLower:
return workflows.DifficultyLower
case ActionRaise:
return workflows.DifficultyRaise
default:
return workflows.DifficultyHold
}
}
func readinessPercentage(concepts []ConceptProgress) int {
if len(concepts) == 0 {
return 0
}
total := 0
for _, concept := range concepts {
total += readinessScore(concept.State)
}
return total * 100 / (len(concepts) * 4)
}
func readinessScore(state workflows.ReadinessState) int {
switch state {
case workflows.ReadinessFragile:
return 1
case workflows.ReadinessImproving:
return 2
case workflows.ReadinessInterviewReady:
return 3
case workflows.ReadinessStrongSignal:
return 4
default:
return 0
}
}
func rewardsFromConcepts(concepts []ConceptProgress) []Reward {
rewards := []Reward{}
for _, concept := range concepts {
if len(concept.Evidence) == 0 || readinessScore(concept.State) < 2 {
continue
}
rewards = append(rewards, Reward{
Kind: RewardConceptProgress,
Label: "Evidence-backed progress on " + concept.Concept.Label,
Evidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
})
}
return rewards
}
func unlocksFromConcepts(concepts []ConceptProgress) []Unlock {
stable := []workflows.EvidenceRef{}
for _, concept := range concepts {
if readinessScore(concept.State) < 3 || len(concept.Evidence) == 0 {
continue
}
stable = append(stable, concept.Evidence...)
}
if len(stable) < 2 {
return []Unlock{}
}
return []Unlock{{
Kind: UnlockBossQuestion,
Label: "Integrated backend interview boss question",
Evidence: append([]workflows.EvidenceRef(nil), stable...),
}}
}
func trackFromConcepts(concepts []ConceptProgress) string {
if len(concepts) == 0 {
return ""
}
return concepts[0].Concept.Track
}
func challengeQuestion(concept ConceptProgress) string {
switch concept.LadderLevel {
case workflows.LadderTradeoffs:
return "Explain a production tradeoff for " + concept.Concept.Label + "."
case workflows.LadderDesignConstraints:
return "Design a constrained backend scenario that uses " + concept.Concept.Label + "."
case workflows.LadderInterviewPressure:
return "Answer a timed interview follow-up about " + concept.Concept.Label + "."
default:
return "Define " + concept.Concept.Label + " and give one concrete backend example."
}
}

View File

@@ -0,0 +1,97 @@
package progression
import (
"testing"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
func TestReadinessMapUsesEvidenceBackedMemory(t *testing.T) {
service := seededService(t, workflows.ReadinessImproving)
readiness, err := service.ReadinessMap("user-1")
if err != nil {
t.Fatalf("ReadinessMap error: %v", err)
}
if readiness.ReadinessPercentage != 50 {
t.Fatalf("readiness = %d, want 50", readiness.ReadinessPercentage)
}
if len(readiness.Concepts) != 1 {
t.Fatalf("concepts = %d, want 1", len(readiness.Concepts))
}
if readiness.Concepts[0].LadderLevel != workflows.LadderTradeoffs {
t.Fatalf("ladder = %q", readiness.Concepts[0].LadderLevel)
}
if len(readiness.Rewards) != 1 {
t.Fatalf("rewards = %d, want 1", len(readiness.Rewards))
}
}
func TestNextChallengeTargetsWeakestConcept(t *testing.T) {
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
if _, err := memory.EnsureProfile(learnermemory.ProfileInput{
UserID: "user-1",
TargetRole: "backend developer",
Stack: []string{"go"},
}); err != nil {
t.Fatalf("EnsureProfile error: %v", err)
}
evidence := []workflows.EvidenceRef{{Kind: workflows.EvidenceAnswer, ID: "a-1", Confidence: 1}}
if err := memory.ApplyCandidate(workflows.MemoryUpdateCandidate{
UserID: "user-1",
Updates: []workflows.MemoryUpdate{
{
Kind: workflows.MemoryConceptMastery,
Concept: workflows.ConceptRef{ID: "cache", Label: "Cache invalidation", Track: "backend-developer"},
ProposedState: workflows.ReadinessInterviewReady,
Evidence: evidence,
},
{
Kind: workflows.MemoryConceptMastery,
Concept: workflows.ConceptRef{ID: "indexes", Label: "Database indexes", Track: "backend-developer"},
ProposedState: workflows.ReadinessFragile,
Evidence: evidence,
},
},
}); err != nil {
t.Fatalf("ApplyCandidate error: %v", err)
}
challenge, err := NewService(memory).NextChallenge("user-1")
if err != nil {
t.Fatalf("NextChallenge error: %v", err)
}
if challenge.Concept.ID != "indexes" {
t.Fatalf("challenge concept = %q", challenge.Concept.ID)
}
if challenge.DifficultyAction != workflows.DifficultyRecover {
t.Fatalf("difficulty = %q", challenge.DifficultyAction)
}
}
func seededService(t *testing.T, state workflows.ReadinessState) *Service {
t.Helper()
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
if _, err := memory.EnsureProfile(learnermemory.ProfileInput{
UserID: "user-1",
TargetRole: "backend developer",
Stack: []string{"go"},
}); err != nil {
t.Fatalf("EnsureProfile error: %v", err)
}
if err := memory.ApplyCandidate(workflows.MemoryUpdateCandidate{
UserID: "user-1",
Updates: []workflows.MemoryUpdate{
{
Kind: workflows.MemoryConceptMastery,
Concept: workflows.ConceptRef{ID: "idempotency", Label: "HTTP idempotency", Track: "backend-developer"},
ProposedState: state,
Evidence: []workflows.EvidenceRef{{Kind: workflows.EvidenceAnswer, ID: "a-1", Confidence: 1}},
},
},
}); err != nil {
t.Fatalf("ApplyCandidate error: %v", err)
}
return NewService(memory)
}

View File

@@ -0,0 +1,54 @@
package progression
import "tutor/internal/workflows"
type ReadinessMap struct {
UserID string `json:"user_id"`
Track string `json:"track"`
ReadinessPercentage int `json:"readiness_percentage"`
Concepts []ConceptProgress `json:"concepts"`
Rewards []Reward `json:"rewards"`
Unlocks []Unlock `json:"unlocks"`
}
type ConceptProgress struct {
Concept workflows.ConceptRef `json:"concept"`
State workflows.ReadinessState `json:"state"`
LadderLevel workflows.LadderLevel `json:"ladder_level"`
NextAction DifficultyAction `json:"next_action"`
Evidence []workflows.EvidenceRef `json:"evidence"`
}
type DifficultyAction string
const (
ActionRecover DifficultyAction = "recover"
ActionLower DifficultyAction = "lower"
ActionHold DifficultyAction = "hold"
ActionRaise DifficultyAction = "raise"
)
type Reward struct {
Kind RewardKind `json:"kind"`
Label string `json:"label"`
Evidence []workflows.EvidenceRef `json:"evidence"`
}
type RewardKind string
const (
RewardConceptProgress RewardKind = "concept_progress"
RewardReadiness RewardKind = "readiness"
)
type Unlock struct {
Kind UnlockKind `json:"kind"`
Label string `json:"label"`
Evidence []workflows.EvidenceRef `json:"evidence"`
}
type UnlockKind string
const (
UnlockBossQuestion UnlockKind = "boss_question"
)

View File

@@ -0,0 +1,96 @@
package teachingassets
import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"tutor/internal/ontology"
"tutor/internal/workflows"
)
type Service struct {
store Store
ontology *ontology.Service
imageModelKey string
ids atomic.Uint64
}
func NewService(store Store, ontology *ontology.Service, imageModelKey string) *Service {
return &Service{
store: store,
ontology: ontology,
imageModelKey: imageModelKey,
}
}
func (s *Service) GeneratePrompt(input GenerateInput) (PromptCandidate, error) {
if strings.TrimSpace(input.ConceptID) == "" {
return PromptCandidate{}, errors.New("concept_id is required")
}
assetType := input.AssetType
if assetType == "" {
assetType = workflows.AssetDiagram
}
concept, err := s.findConcept(input.ConceptID)
if err != nil {
return PromptCandidate{}, err
}
if len(concept.Evidence) == 0 {
return PromptCandidate{}, errors.New("concept has no source evidence")
}
prompt := PromptCandidate{
ID: s.nextID("asset-prompt"),
Concept: concept.Concept,
AssetType: assetType,
Prompt: buildPrompt(concept.Concept, assetType),
SourceEvidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
ModelKey: s.imageModelKey,
RequiresModelIDVerification: true,
ReviewState: ReviewCandidate,
CreatedAt: time.Now().UTC(),
}
return s.store.SavePrompt(prompt)
}
func (s *Service) Snapshot() Snapshot {
return s.store.Snapshot()
}
func (s *Service) findConcept(id string) (ontology.ConceptCandidate, error) {
if s.ontology == nil {
return ontology.ConceptCandidate{}, errors.New("ontology not configured")
}
snapshot := s.ontology.Snapshot()
for _, concept := range snapshot.Concepts {
if concept.Concept.ID == id {
return concept, nil
}
}
return ontology.ConceptCandidate{}, errors.New("concept not found")
}
func buildPrompt(concept workflows.ConceptRef, assetType workflows.AssetType) string {
switch assetType {
case workflows.AssetLessonSlice:
return "Create a concise slide-like lesson slice explaining " + concept.Label +
" for a backend developer interview, with one example and one pitfall."
case workflows.AssetWorksheet:
return "Create a worksheet for practicing " + concept.Label +
" with short prompts, answer space, and a rubric."
case workflows.AssetInterviewCard:
return "Create an interview explanation card for " + concept.Label +
" with definition, production tradeoff, and follow-up question."
default:
return "Create a clear technical diagram explaining " + concept.Label +
" for a backend developer interview, grounded in the provided source evidence."
}
}
func (s *Service) nextID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, s.ids.Add(1))
}

View File

@@ -0,0 +1,39 @@
package teachingassets
import (
"testing"
"tutor/internal/ontology"
"tutor/internal/workflows"
)
func TestGeneratePromptKeepsLineageAndVerificationGuard(t *testing.T) {
onto := ontology.NewService(ontology.NewMemoryStore())
if _, err := onto.Ingest(ontology.IngestInput{
Title: "Backend notes",
Body: "Idempotent API retries need transactions.",
}); err != nil {
t.Fatalf("Ingest error: %v", err)
}
service := NewService(NewMemoryStore(), onto, "gpt-image-v2")
prompt, err := service.GeneratePrompt(GenerateInput{
ConceptID: "http-idempotency",
AssetType: workflows.AssetDiagram,
})
if err != nil {
t.Fatalf("GeneratePrompt error: %v", err)
}
if prompt.ModelKey != "gpt-image-v2" {
t.Fatalf("ModelKey = %q", prompt.ModelKey)
}
if !prompt.RequiresModelIDVerification {
t.Fatal("expected model id verification guard")
}
if prompt.ReviewState != ReviewCandidate {
t.Fatalf("ReviewState = %q", prompt.ReviewState)
}
if len(prompt.SourceEvidence) == 0 {
t.Fatal("expected source evidence")
}
}

View File

@@ -0,0 +1,49 @@
package teachingassets
import (
"sync"
"tutor/internal/workflows"
)
type Store interface {
SavePrompt(PromptCandidate) (PromptCandidate, error)
Snapshot() Snapshot
}
type MemoryStore struct {
mu sync.RWMutex
prompts []PromptCandidate
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (s *MemoryStore) SavePrompt(prompt PromptCandidate) (PromptCandidate, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.prompts = append(s.prompts, clonePrompt(prompt))
return prompt, nil
}
func (s *MemoryStore) Snapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return Snapshot{Prompts: clonePrompts(s.prompts)}
}
func clonePrompts(items []PromptCandidate) []PromptCandidate {
cloned := make([]PromptCandidate, len(items))
for i, item := range items {
cloned[i] = clonePrompt(item)
}
return cloned
}
func clonePrompt(prompt PromptCandidate) PromptCandidate {
prompt.SourceEvidence = append([]workflows.EvidenceRef(nil), prompt.SourceEvidence...)
return prompt
}

View File

@@ -0,0 +1,34 @@
package teachingassets
import (
"time"
"tutor/internal/workflows"
)
type ReviewState string
const (
ReviewCandidate ReviewState = "candidate"
)
type PromptCandidate struct {
ID string `json:"id"`
Concept workflows.ConceptRef `json:"concept"`
AssetType workflows.AssetType `json:"asset_type"`
Prompt string `json:"prompt"`
SourceEvidence []workflows.EvidenceRef `json:"source_evidence"`
ModelKey string `json:"model_key"`
RequiresModelIDVerification bool `json:"requires_model_id_verification"`
ReviewState ReviewState `json:"review_state"`
CreatedAt time.Time `json:"created_at"`
}
type GenerateInput struct {
ConceptID string
AssetType workflows.AssetType
}
type Snapshot struct {
Prompts []PromptCandidate `json:"prompts"`
}

41
internal/webapp/assets.go Normal file
View File

@@ -0,0 +1,41 @@
package webapp
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed static/*
var assets embed.FS
func Handler() http.Handler {
static, err := fs.Sub(assets, "static")
if err != nil {
panic(err)
}
files := http.FileServer(http.FS(static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
serveIndex(w, r, static)
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
http.StripPrefix("/assets/", files).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
func serveIndex(w http.ResponseWriter, r *http.Request, static fs.FS) {
content, err := fs.ReadFile(static, "index.html")
if err != nil {
http.Error(w, "web app unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(content)
}

View File

@@ -0,0 +1,42 @@
package webapp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerServesIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Interview practice") {
t.Fatal("expected app shell content")
}
}
func TestHandlerServesAsset(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "diagnostic-sessions") {
t.Fatal("expected app script content")
}
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")
}
}

View File

@@ -0,0 +1,400 @@
const state = {
session: null,
selectedQuestion: null,
lastAnswer: null,
progress: null,
ontology: null,
assetPrompt: null,
};
const els = {
sessionForm: document.querySelector("#session-form"),
answerForm: document.querySelector("#answer-form"),
answerText: document.querySelector("#answer-text"),
answerButton: document.querySelector("#answer-button"),
questions: document.querySelector("#questions"),
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"),
};
const readinessClassMap = {
unknown: "pill-neutral",
fragile: "pill-weak",
improving: "pill-warn",
interview_ready: "pill-good",
strong_signal: "pill-strong",
};
const reviewClassMap = {
candidate: "pill-neutral",
reviewed: "pill-good",
};
const gradeClassMap = {
miss: "grade-miss",
partial: "grade-partial",
solid: "grade-solid",
strong: "grade-strong",
};
function setButtonLoading(button, loadingText) {
button.disabled = true;
button.classList.add("is-loading");
const textEl = button.querySelector(".btn-text");
if (textEl && loadingText) {
textEl.dataset.originalText = textEl.textContent;
textEl.textContent = loadingText;
}
}
function clearButtonLoading(button) {
button.disabled = false;
button.classList.remove("is-loading");
const textEl = button.querySelector(".btn-text");
if (textEl && textEl.dataset.originalText) {
textEl.textContent = textEl.dataset.originalText;
delete textEl.dataset.originalText;
}
}
els.sessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus("Creating diagnostic session...", true);
setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting...");
const payload = {
user_id: value("#user-id"),
target_role: value("#target-role"),
stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean),
interview_timeline: value("#timeline"),
};
try {
const session = await request("/api/v1/diagnostic-sessions", {
method: "POST",
body: JSON.stringify(payload),
});
state.session = session;
state.selectedQuestion = session.questions[0] || null;
state.lastAnswer = null;
renderSession();
renderFeedback();
renderProgress();
setStatus(`Session ${session.id} ready`);
} catch (error) {
showError(error.message);
setStatus("Ready");
} finally {
clearButtonLoading(document.querySelector("#start-button"));
}
});
els.refreshProgress.addEventListener("click", async () => {
clearError();
await refreshProgress();
});
els.answerForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
if (!state.session || !state.selectedQuestion) return;
setStatus("Submitting answer...", true);
setButtonLoading(event.submitter || els.answerButton, "Grading...");
try {
const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, {
method: "POST",
body: JSON.stringify({
question_id: state.selectedQuestion.id,
answer_text: els.answerText.value,
}),
});
state.lastAnswer = answer;
renderFeedback();
await refreshProgress();
setStatus(`Answer graded as ${answer.grade.overall}`);
} catch (error) {
showError(error.message);
setStatus("Session ready");
} finally {
clearButtonLoading(els.answerButton);
els.answerButton.disabled = !state.selectedQuestion;
}
});
els.materialForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus("Ingesting material...", true);
setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting...");
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");
} finally {
clearButtonLoading(document.querySelector("#material-button"));
}
});
els.assetForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
setStatus("Generating prompt candidate...", true);
setButtonLoading(event.submitter || els.assetButton, "Generating...");
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");
} finally {
clearButtonLoading(els.assetButton);
}
});
function renderSession() {
if (!state.session) return;
els.title.textContent = `${state.session.target_role}${state.session.questions.length} questions`;
els.questions.className = "question-list";
els.questions.innerHTML = "";
state.session.questions.forEach((question) => {
const button = document.createElement("button");
button.type = "button";
button.className = "question-button";
button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
button.innerHTML = `<span class="question-id">${escapeHTML(question.id)}</span>${escapeHTML(question.prompt)}`;
button.addEventListener("click", () => {
state.selectedQuestion = question;
els.answerText.value = "";
renderSession();
setStatus(`Selected ${question.id}`);
});
els.questions.append(button);
});
els.answerButton.disabled = !state.selectedQuestion;
}
async function refreshProgress() {
if (!state.session) return;
setStatus("Refreshing learning progress...", true);
try {
const userID = encodeURIComponent(state.session.user_id);
const [memory, readiness, challenge] = await Promise.all([
request(`/api/v1/learners/${userID}/memory`),
request(`/api/v1/learners/${userID}/readiness-map`),
request(`/api/v1/learners/${userID}/next-challenge`),
]);
state.progress = { memory, readiness, challenge };
renderProgress();
setStatus("Learning progress updated");
} catch (error) {
showError(error.message);
renderProgress();
}
}
function renderProgress() {
els.refreshProgress.disabled = !state.session;
if (!state.progress) {
els.progress.className = "feedback empty-state";
els.progress.innerHTML = `<span class="empty-hint">Answer once to update learner memory and readiness.</span>`;
return;
}
const { memory, readiness, challenge } = state.progress;
const mastery = memory.mastery || [];
els.progress.className = "feedback";
els.progress.innerHTML = `
<section>
<div class="readiness-value">${readiness.readiness_percentage}%</div>
<p class="status-line">${escapeHTML(memory.profile.target_role)} readiness</p>
</section>
<section>
<h2>Concept memory</h2>
<div>${mastery.map((item) => {
const cls = readinessClassMap[item.state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.state)}</span>`;
}).join("")}</div>
</section>
<section>
<h2>Next challenge</h2>
<p class="status-line">${escapeHTML(challenge.concept.label)}${escapeHTML(challenge.ladder_level)}</p>
<p>${escapeHTML(challenge.question)}</p>
</section>
`;
}
function renderOntology() {
if (!state.ontology) {
els.ontology.className = "ontology-view empty-state";
els.ontology.innerHTML = `<span class="empty-hint">Ingest material to inspect ontology candidates.</span>`;
return;
}
const concepts = state.ontology.concepts || [];
els.ontology.className = "ontology-view";
els.ontology.innerHTML = `
<div class="summary-strip">
<span class="summary-chip">${concepts.length} concepts</span>
<span class="summary-chip">${(state.ontology.edges || []).length} edges</span>
<span class="summary-chip">${(state.ontology.gaps || []).length} gaps</span>
</div>
<section>
<h2>Candidate concepts</h2>
<div>${concepts.map((item) => {
const cls = reviewClassMap[item.review_state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.review_state)}</span>`;
}).join("") || "No candidates yet."}</div>
</section>
`;
els.assetConcept.innerHTML = concepts
.map((item) => `<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`)
.join("");
els.assetConcept.disabled = concepts.length === 0;
els.assetButton.disabled = concepts.length === 0;
}
function renderAssetPrompt() {
if (!state.assetPrompt) {
els.assetOutput.className = "ontology-view empty-state";
els.assetOutput.innerHTML = `<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>`;
return;
}
const prompt = state.assetPrompt;
els.assetOutput.className = "ontology-view";
els.assetOutput.innerHTML = `
<div class="summary-strip">
<span class="summary-chip">${escapeHTML(prompt.model_key)}</span>
<span class="summary-chip">${escapeHTML(prompt.review_state)}</span>
<span class="summary-chip">verify model id: ${prompt.requires_model_id_verification ? "yes" : "no"}</span>
</div>
<pre class="prompt-text">${escapeHTML(prompt.prompt)}</pre>
${evidenceBlock(prompt.source_evidence)}
`;
}
function renderFeedback() {
if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state";
els.feedback.innerHTML = `<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>`;
return;
}
const grade = state.lastAnswer.grade;
const gradeClass = gradeClassMap[grade.overall] || "";
els.feedback.className = "feedback";
els.feedback.innerHTML = `
<div>
<div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
</div>
${scoreRows(grade.scores)}
${listBlock("Gaps", grade.gaps)}
${followUpBlock(grade.follow_up)}
${evidenceBlock(grade.evidence)}
`;
}
function scoreRows(scores) {
return Object.entries(scores || {})
.map(([label, score]) => `
<div class="metric-row">
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
<strong>${score}/4</strong>
</div>
`)
.join("");
}
function listBlock(title, items = []) {
if (!items.length) return "";
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
}
function followUpBlock(followUp) {
if (!followUp?.needed) return "";
return `<section><h2>Follow-up</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
}
function evidenceBlock(evidence = []) {
if (!evidence.length) return "";
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || `Request failed: ${response.status}`);
}
return body;
}
function value(selector) {
return document.querySelector(selector).value.trim();
}
function setStatus(message, busy = false) {
const textEl = els.status.querySelector(".status-text");
if (textEl) textEl.textContent = message;
else els.status.textContent = message;
els.status.classList.toggle("is-busy", busy);
}
function showError(message) {
els.error.textContent = message;
}
function clearError() {
els.error.textContent = "";
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}

View File

@@ -0,0 +1,149 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tutor Platform</title>
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<main class="workspace">
<aside class="setup-pane" aria-label="Diagnostic setup">
<p class="eyebrow">Tutor Platform</p>
<h1>Interview practice</h1>
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
<form id="session-form" class="stacked-form">
<label>
User ID
<input id="user-id" name="user_id" value="demo-user" autocomplete="off" />
</label>
<label>
Target role
<input id="target-role" name="target_role" value="junior backend developer" />
</label>
<label>
Stack
<input id="stack" name="stack" value="go, postgres" />
</label>
<label>
Timeline
<input id="timeline" name="interview_timeline" value="30 days" />
</label>
<button id="start-button" type="submit">
<span class="btn-text">Start diagnostic</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form>
<p id="status-line" class="status-line" role="status">
<span class="status-icon" aria-hidden="true"></span>
<span class="status-text">Ready</span>
</p>
<p id="error-line" class="error-line" role="alert"></p>
</aside>
<section class="practice-pane" aria-label="Diagnostic practice">
<div class="section-heading">
<div>
<p class="eyebrow">Diagnostic</p>
<h2 id="session-title">No active session</h2>
</div>
</div>
<div id="questions" class="question-list empty-state">
<span class="empty-hint">Start a diagnostic session to load interview questions.</span>
</div>
<form id="answer-form" class="answer-form">
<label for="answer-text">Answer</label>
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
<button id="answer-button" type="submit" disabled>
<span class="btn-text">Submit answer</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form>
<section class="content-workspace" aria-label="Material and asset workspace">
<div class="section-heading">
<div>
<p class="eyebrow">Content operations</p>
<h2>Source to asset prompt</h2>
</div>
</div>
<form id="material-form" class="material-form">
<label>
Material title
<input id="material-title" value="Backend interview notes" />
</label>
<label>
Source type
<input id="material-source" value="markdown" />
</label>
<label class="wide-field">
Source material
<textarea id="material-body" rows="5">Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea>
</label>
<button id="material-button" type="submit">
<span class="btn-text">Ingest material</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form>
<div id="ontology" class="ontology-view empty-state">
<span class="empty-hint">Ingest material to inspect ontology candidates.</span>
</div>
<form id="asset-form" class="asset-form">
<label>
Concept
<select id="asset-concept" disabled>
<option value="">Select a concept</option>
</select>
</label>
<label>
Asset type
<select id="asset-type">
<option value="diagram">Diagram</option>
<option value="lesson_slice">Lesson slice</option>
<option value="worksheet">Worksheet</option>
<option value="interview_card">Interview card</option>
</select>
</label>
<button id="asset-button" type="submit" disabled>
<span class="btn-text">Generate prompt</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form>
<div id="asset-output" class="ontology-view empty-state">
<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>
</div>
</section>
</section>
<aside class="feedback-pane" aria-label="Feedback">
<div class="section-heading">
<div>
<p class="eyebrow">Feedback</p>
<h2>Rubric result</h2>
</div>
</div>
<div id="feedback" class="feedback empty-state">
<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>
</div>
<div class="section-heading progress-heading">
<div>
<p class="eyebrow">Progress</p>
<h2>Learning state</h2>
</div>
<button id="refresh-progress" class="small-button" type="button" disabled>Refresh</button>
</div>
<div id="progress" class="feedback empty-state">
<span class="empty-hint">Answer once to update learner memory and readiness.</span>
</div>
</aside>
</main>
<script src="/assets/app.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,474 @@
:root {
color-scheme: light;
--bg: #f5f7f4;
--surface: #ffffff;
--surface-muted: #eef2ec;
--text: #18201b;
--muted: #5b665f;
--line: #d8dfd5;
--accent: #19764b;
--accent-dark: #105c39;
--danger: #a93a2f;
--warn: #b45f1a;
--warn-bg: #fff6eb;
--weak: #a93a2f;
--weak-bg: #fdf2f1;
--good: #1a6b8f;
--good-bg: #eef7fb;
--strong: #19764b;
--strong-bg: #f0faf3;
--neutral: #6b7570;
--neutral-bg: #f4f5f4;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button,
input,
textarea,
select {
font: inherit;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(360px, 1fr) minmax(280px, 360px);
gap: 1px;
min-height: 100vh;
background: var(--line);
}
.setup-pane,
.practice-pane,
.feedback-pane {
background: var(--surface);
padding: 28px;
}
.practice-pane {
display: flex;
flex-direction: column;
gap: 22px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
font-size: 12px;
font-weight: 750;
letter-spacing: 0.06em;
}
html[lang="ko"] .eyebrow {
text-transform: none;
letter-spacing: 0.02em;
}
h1,
h2 {
margin: 0;
line-height: 1.08;
}
h1 {
font-size: clamp(36px, 5vw, 64px);
line-height: 1.05;
}
h2 {
font-size: 22px;
}
.lede {
max-width: 28ch;
margin: 18px 0 30px;
color: var(--muted);
line-height: 1.5;
}
.stacked-form,
.answer-form {
display: grid;
gap: 14px;
}
.material-form,
.asset-form {
align-items: end;
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(180px, 1fr)) auto;
}
.wide-field {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 650;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfa;
color: var(--text);
padding: 12px;
outline: none;
appearance: none;
}
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b665f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
textarea {
min-height: 160px;
resize: vertical;
line-height: 1.45;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(25, 118, 75, 0.12);
}
button {
min-height: 44px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 750;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
}
button:hover:not(:disabled) {
background: var(--accent-dark);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.btn-spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
button.is-loading .btn-text {
opacity: 0.9;
}
button.is-loading .btn-spinner {
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
margin: 18px 0 0;
font-size: 13px;
color: var(--muted);
}
.status-icon {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
.status-line.is-busy .status-icon {
background: var(--warn);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.error-line {
min-height: 20px;
margin: 10px 0 0;
font-size: 13px;
color: var(--danger);
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.question-list {
display: grid;
gap: 10px;
}
.question-button {
border: 1px solid var(--line);
border-left: 3px solid transparent;
background: #fbfcfa;
color: var(--text);
padding: 16px;
min-height: 72px;
text-align: left;
border-radius: 6px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.question-button:hover:not(:disabled) {
border-color: var(--accent);
}
.question-button[aria-pressed="true"] {
border-color: var(--accent);
border-left-color: var(--accent);
background: var(--surface-muted);
}
.question-id {
display: block;
margin-bottom: 5px;
color: var(--accent);
font-size: 12px;
font-weight: 750;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--muted);
padding: 22px 18px;
text-align: center;
font-size: 13px;
line-height: 1.5;
}
.empty-hint::before {
content: "";
display: block;
width: 24px;
height: 24px;
margin: 0 auto 10px;
opacity: 0.45;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%235b665f' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
background-size: contain;
}
.feedback {
display: grid;
gap: 16px;
margin-top: 22px;
}
.progress-heading {
margin-top: 34px;
}
.small-button {
min-height: 34px;
padding: 0 12px;
font-size: 12px;
}
.metric-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.metric-row:last-child {
border-bottom: 0;
}
.grade {
font-size: 38px;
font-weight: 800;
line-height: 1.1;
}
.grade-miss {
color: var(--weak);
}
.grade-partial {
color: var(--warn);
}
.grade-solid {
color: var(--good);
}
.grade-strong {
color: var(--strong);
}
.readiness-value {
color: var(--accent);
font-size: 48px;
font-weight: 850;
line-height: 1;
}
.concept-pill {
display: inline-flex;
margin: 4px 6px 4px 0;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
background: var(--surface);
}
.pill-neutral {
background: var(--neutral-bg);
border-color: #d5dad3;
color: var(--neutral);
}
.pill-weak {
background: var(--weak-bg);
border-color: #e8bdb9;
color: var(--weak);
}
.pill-warn {
background: var(--warn-bg);
border-color: #edd5b5;
color: var(--warn);
}
.pill-good {
background: var(--good-bg);
border-color: #b8d9e8;
color: var(--good);
}
.pill-strong {
background: var(--strong-bg);
border-color: #b8dfc6;
color: var(--strong);
}
.small-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.45;
}
.content-workspace {
border-top: 2px solid var(--line);
display: grid;
gap: 18px;
padding-top: 28px;
margin-top: 6px;
}
.ontology-view {
display: grid;
gap: 12px;
}
.summary-strip {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.summary-chip {
background: var(--surface-muted);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 750;
padding: 7px 12px;
}
.prompt-text {
background: #fbfcfa;
border: 1px solid var(--line);
border-radius: 6px;
margin: 0;
padding: 14px;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
color: var(--text);
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
h1 {
font-size: 42px;
}
.material-form,
.asset-form {
grid-template-columns: 1fr;
}
}

View File

@@ -192,3 +192,52 @@ const (
UnlockReviewCard UnlockKind = "review_card" UnlockReviewCard UnlockKind = "review_card"
UnlockPortfolioEntry UnlockKind = "portfolio_entry" UnlockPortfolioEntry UnlockKind = "portfolio_entry"
) )
type OntologyGap struct {
Track string `json:"track"`
MissingOrWeak []OntologyGapItem `json:"missing_or_weak"`
}
type OntologyGapItem struct {
Concept ConceptRef `json:"concept"`
GapType OntologyGapType `json:"gap_type"`
Reason string `json:"reason"`
SupportingSources []EvidenceRef `json:"supporting_sources"`
ProposedAction GapAction `json:"proposed_action"`
}
type OntologyGapType string
const (
OntologyGapMissingPrerequisite OntologyGapType = "missing_prerequisite"
OntologyGapWeakEvidence OntologyGapType = "weak_evidence"
OntologyGapOutdated OntologyGapType = "outdated"
OntologyGapNeedsRubric OntologyGapType = "needs_rubric"
)
type GapAction string
const (
GapActionGenerateCandidate GapAction = "generate_candidate"
GapActionRequestSource GapAction = "request_source"
GapActionHumanReview GapAction = "human_review"
)
type TeachingAssetPrompt struct {
Concept ConceptRef `json:"concept"`
AssetType AssetType `json:"asset_type"`
Prompt string `json:"prompt"`
SourceEvidence []EvidenceRef `json:"source_evidence"`
ModelKey string `json:"model_key"`
RequiresModelIDVerification bool `json:"requires_model_id_verification"`
ReviewState string `json:"review_state"`
}
type AssetType string
const (
AssetDiagram AssetType = "diagram"
AssetLessonSlice AssetType = "lesson_slice"
AssetWorksheet AssetType = "worksheet"
AssetInterviewCard AssetType = "interview_card"
)

View File

@@ -14,3 +14,6 @@
- [ ] 10. Draft the first `agent-farm-go` YAML workflow package. - [ ] 10. Draft the first `agent-farm-go` YAML workflow package.
- [x] 11. Validate the OpenSpec change. - [x] 11. Validate the OpenSpec change.
- [x] 12. Implement evidence-backed learner memory ingestion and readback. - [x] 12. Implement evidence-backed learner memory ingestion and readback.
- [x] 13. Implement evidence-backed readiness map and next challenge APIs.
- [x] 14. Implement source-backed ontology material ingestion.
- [x] 15. Implement source-backed teaching asset prompt candidates.

View File

@@ -0,0 +1,30 @@
# Design: Frontend MVP
## Approach
Use a dependency-light web app served by the Go backend. The first UI can be
plain HTML/CSS/JavaScript so the repo avoids frontend build tooling until the
interaction model is proven.
## UX Principles
- First screen is the working tutor experience, not a marketing landing page.
- Keep the UI quiet, operational, and focused on repeated practice.
- Show evidence-backed feedback and next actions after each loop.
- Use clear loading, empty, and error states.
## Boundaries
In scope:
- Diagnostic practice.
- Learner memory and readiness display.
- Material ingestion and ontology inspection.
- Teaching asset prompt candidates.
Out of scope:
- Authentication.
- Multi-user persistence.
- Payment/subscription.
- Real provider image generation.

View File

@@ -0,0 +1,19 @@
# Change: frontend-mvp
## Why
The backend MVP now exposes the core tutoring loop, but job seekers still need
a web service experience instead of API tooling.
## What Changes
- Serve a web app from the Go backend.
- Add diagnostic practice UI.
- Add learner progress UI.
- Add material ingestion, ontology inspection, and teaching asset prompt UI.
## Impact
- Adds frontend capability requirements.
- Keeps Go backend as the serving boundary for the MVP.
- Does not introduce authentication, persistence, or real image generation.

View File

@@ -0,0 +1,44 @@
# frontend-mvp Specification
## ADDED Requirements
### Requirement: Web app exposes the job-seeker tutoring loop
The system SHALL provide a browser UI for the diagnostic interview loop.
#### Scenario: user starts diagnostic practice
- **GIVEN** a job seeker opens the web app
- **WHEN** they enter target role, stack, and interview timeline
- **THEN** the app creates a diagnostic session
- **AND** shows role-relevant questions.
#### Scenario: user submits an answer
- **GIVEN** a diagnostic session is active
- **WHEN** the user submits an answer
- **THEN** the app shows typed grading feedback
- **AND** the feedback links to evidence-backed learning state.
### Requirement: Web app exposes learning progress
The system SHALL show learner memory, readiness, and next challenge from the
backend APIs.
#### Scenario: progress appears after answer
- **GIVEN** the user has submitted at least one answer
- **WHEN** the app refreshes progress
- **THEN** it shows concept mastery, readiness percentage, and next challenge.
### Requirement: Web app exposes content operations
The system SHALL provide MVP operator controls for material ingestion,
ontology inspection, and teaching asset prompt candidates.
#### Scenario: operator creates teaching asset prompt
- **GIVEN** source material has produced ontology candidates
- **WHEN** the operator generates an asset prompt for a concept
- **THEN** the prompt shows source evidence, review state, model key, and model
verification guard.

View File

@@ -0,0 +1,8 @@
# Tasks
- [x] 1. Implement web app shell served by the Go backend.
- [x] 2. Implement diagnostic session start and answer submission UI.
- [x] 3. Implement learner memory, readiness, and next challenge UI.
- [x] 4. Implement material ingestion and ontology inspection UI.
- [x] 5. Implement teaching asset prompt candidate UI.
- [x] 6. Validate frontend MVP with tests, smoke checks, and OpenSpec.