Compare commits
10 Commits
600acf7303
...
01d102f5ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d102f5ef | ||
|
|
c54da12a4c | ||
|
|
b570c93d94 | ||
|
|
7866f6dcb3 | ||
|
|
ce38189f33 | ||
|
|
3493f8b5a5 | ||
|
|
4bb1d07f94 | ||
|
|
156daa9087 | ||
|
|
4936cdf4c9 | ||
|
|
a413f1ef15 |
@@ -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.*
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
39
.planning/phases/004-progression/004-CONTEXT.md
Normal file
39
.planning/phases/004-progression/004-CONTEXT.md
Normal 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.
|
||||||
46
.planning/phases/004-progression/004-PLAN.md
Normal file
46
.planning/phases/004-progression/004-PLAN.md
Normal 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.
|
||||||
32
.planning/phases/004-progression/004-RESEARCH.md
Normal file
32
.planning/phases/004-progression/004-RESEARCH.md
Normal 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.
|
||||||
35
.planning/phases/004-progression/004-SUMMARY.md
Normal file
35
.planning/phases/004-progression/004-SUMMARY.md
Normal 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.
|
||||||
28
.planning/phases/004-progression/004-VERIFICATION.md
Normal file
28
.planning/phases/004-progression/004-VERIFICATION.md
Normal 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.
|
||||||
37
.planning/phases/005-ontology-materials/005-CONTEXT.md
Normal file
37
.planning/phases/005-ontology-materials/005-CONTEXT.md
Normal 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.
|
||||||
42
.planning/phases/005-ontology-materials/005-PLAN.md
Normal file
42
.planning/phases/005-ontology-materials/005-PLAN.md
Normal 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.
|
||||||
28
.planning/phases/005-ontology-materials/005-RESEARCH.md
Normal file
28
.planning/phases/005-ontology-materials/005-RESEARCH.md
Normal 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.
|
||||||
36
.planning/phases/005-ontology-materials/005-SUMMARY.md
Normal file
36
.planning/phases/005-ontology-materials/005-SUMMARY.md
Normal 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.
|
||||||
29
.planning/phases/005-ontology-materials/005-VERIFICATION.md
Normal file
29
.planning/phases/005-ontology-materials/005-VERIFICATION.md
Normal 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.
|
||||||
39
.planning/phases/006-teaching-assets/006-CONTEXT.md
Normal file
39
.planning/phases/006-teaching-assets/006-CONTEXT.md
Normal 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.
|
||||||
44
.planning/phases/006-teaching-assets/006-PLAN.md
Normal file
44
.planning/phases/006-teaching-assets/006-PLAN.md
Normal 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.
|
||||||
21
.planning/phases/006-teaching-assets/006-RESEARCH.md
Normal file
21
.planning/phases/006-teaching-assets/006-RESEARCH.md
Normal 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.
|
||||||
38
.planning/phases/006-teaching-assets/006-SUMMARY.md
Normal file
38
.planning/phases/006-teaching-assets/006-SUMMARY.md
Normal 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.
|
||||||
29
.planning/phases/006-teaching-assets/006-VERIFICATION.md
Normal file
29
.planning/phases/006-teaching-assets/006-VERIFICATION.md
Normal 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
34
.planning/phases/008-learning-progress-view/008-CONTEXT.md
Normal file
34
.planning/phases/008-learning-progress-view/008-CONTEXT.md
Normal 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.
|
||||||
26
.planning/phases/008-learning-progress-view/008-PLAN.md
Normal file
26
.planning/phases/008-learning-progress-view/008-PLAN.md
Normal 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.
|
||||||
20
.planning/phases/008-learning-progress-view/008-RESEARCH.md
Normal file
20
.planning/phases/008-learning-progress-view/008-RESEARCH.md
Normal 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.
|
||||||
33
.planning/phases/008-learning-progress-view/008-SUMMARY.md
Normal file
33
.planning/phases/008-learning-progress-view/008-SUMMARY.md
Normal 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.
|
||||||
@@ -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.
|
||||||
28
.planning/phases/009-material-asset-workspace/009-CONTEXT.md
Normal file
28
.planning/phases/009-material-asset-workspace/009-CONTEXT.md
Normal 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.
|
||||||
34
.planning/phases/009-material-asset-workspace/009-PLAN.md
Normal file
34
.planning/phases/009-material-asset-workspace/009-PLAN.md
Normal 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.
|
||||||
@@ -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.
|
||||||
35
.planning/phases/009-material-asset-workspace/009-SUMMARY.md
Normal file
35
.planning/phases/009-material-asset-workspace/009-SUMMARY.md
Normal 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.
|
||||||
@@ -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.
|
||||||
160
.planning/v1-MILESTONE-AUDIT.md
Normal file
160
.planning/v1-MILESTONE-AUDIT.md
Normal 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.
|
||||||
113
.planning/v2-FRONTEND-MVP-AUDIT.md
Normal file
113
.planning/v2-FRONTEND-MVP-AUDIT.md
Normal 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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
48
internal/httpapi/ontology.go
Normal file
48
internal/httpapi/ontology.go
Normal 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())
|
||||||
|
}
|
||||||
56
internal/httpapi/ontology_test.go
Normal file
56
internal/httpapi/ontology_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/httpapi/progression.go
Normal file
46
internal/httpapi/progression.go
Normal 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)
|
||||||
|
}
|
||||||
47
internal/httpapi/teaching_assets.go
Normal file
47
internal/httpapi/teaching_assets.go
Normal 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())
|
||||||
|
}
|
||||||
64
internal/httpapi/teaching_assets_test.go
Normal file
64
internal/httpapi/teaching_assets_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/ontology/catalog.go
Normal file
38
internal/ontology/catalog.go
Normal 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
|
||||||
|
}
|
||||||
192
internal/ontology/service.go
Normal file
192
internal/ontology/service.go
Normal 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))
|
||||||
|
}
|
||||||
53
internal/ontology/service_test.go
Normal file
53
internal/ontology/service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/ontology/store.go
Normal file
87
internal/ontology/store.go
Normal 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
|
||||||
|
}
|
||||||
91
internal/ontology/types.go
Normal file
91
internal/ontology/types.go
Normal 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"`
|
||||||
|
}
|
||||||
207
internal/progression/service.go
Normal file
207
internal/progression/service.go
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
97
internal/progression/service_test.go
Normal file
97
internal/progression/service_test.go
Normal 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)
|
||||||
|
}
|
||||||
54
internal/progression/types.go
Normal file
54
internal/progression/types.go
Normal 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"
|
||||||
|
)
|
||||||
96
internal/teachingassets/service.go
Normal file
96
internal/teachingassets/service.go
Normal 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))
|
||||||
|
}
|
||||||
39
internal/teachingassets/service_test.go
Normal file
39
internal/teachingassets/service_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/teachingassets/store.go
Normal file
49
internal/teachingassets/store.go
Normal 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
|
||||||
|
}
|
||||||
34
internal/teachingassets/types.go
Normal file
34
internal/teachingassets/types.go
Normal 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
41
internal/webapp/assets.go
Normal 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)
|
||||||
|
}
|
||||||
42
internal/webapp/assets_test.go
Normal file
42
internal/webapp/assets_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
400
internal/webapp/static/app.js
Normal file
400
internal/webapp/static/app.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
149
internal/webapp/static/index.html
Normal file
149
internal/webapp/static/index.html
Normal 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>
|
||||||
474
internal/webapp/static/styles.css
Normal file
474
internal/webapp/static/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
30
openspec/changes/frontend-mvp/design.md
Normal file
30
openspec/changes/frontend-mvp/design.md
Normal 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.
|
||||||
19
openspec/changes/frontend-mvp/proposal.md
Normal file
19
openspec/changes/frontend-mvp/proposal.md
Normal 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.
|
||||||
44
openspec/changes/frontend-mvp/specs/frontend-mvp/spec.md
Normal file
44
openspec/changes/frontend-mvp/specs/frontend-mvp/spec.md
Normal 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.
|
||||||
8
openspec/changes/frontend-mvp/tasks.md
Normal file
8
openspec/changes/frontend-mvp/tasks.md
Normal 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.
|
||||||
Reference in New Issue
Block a user