feat: add teaching asset prompts
This commit is contained in:
@@ -59,11 +59,11 @@ interview-ready after each short practice loop.
|
||||
|
||||
### Teaching Assets
|
||||
|
||||
- [ ] **ASSET-01**: System can generate prompt candidates for visual teaching
|
||||
- [x] **ASSET-01**: System can generate prompt candidates for visual teaching
|
||||
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.
|
||||
- [ ] **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.
|
||||
|
||||
## v2 Requirements
|
||||
@@ -99,7 +99,7 @@ interview-ready after each short practice loop.
|
||||
| MEM-01..MEM-05 | Phase 3 | Complete |
|
||||
| PROG-01..PROG-05 | Phase 4 | Complete |
|
||||
| ONTO-01..ONTO-04 | Phase 5 | Complete |
|
||||
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
||||
| ASSET-01..ASSET-03 | Phase 6 | Complete |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: 28 total
|
||||
@@ -108,4 +108,4 @@ interview-ready after each short practice loop.
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-04-26*
|
||||
*Last updated: 2026-04-26 after Phase 5 execution.*
|
||||
*Last updated: 2026-04-26 after Phase 6 execution.*
|
||||
|
||||
@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
|
||||
**Core value:** The user should feel and prove that they are becoming more
|
||||
interview-ready after each short practice loop.
|
||||
|
||||
**Current focus:** Phase 6 planning: Teaching Assets.
|
||||
**Current focus:** v1 baseline complete; ready for milestone audit or frontend planning.
|
||||
|
||||
## Current Decisions
|
||||
|
||||
@@ -31,13 +31,15 @@ interview-ready after each short practice loop.
|
||||
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.
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. Plan Phase 6 teaching asset prompt generation with GSD.
|
||||
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
|
||||
future workflow implementation.
|
||||
3. Verify the production OpenAI image model identifier before real asset
|
||||
1. Run a milestone audit across Phase 1 through Phase 6.
|
||||
2. Choose the next milestone: frontend MVP, persistence, real workflow runtime,
|
||||
or document parser integration.
|
||||
3. Verify the production OpenAI image model identifier before real image
|
||||
generation calls.
|
||||
|
||||
## Validation Log
|
||||
@@ -61,6 +63,9 @@ interview-ready after each short practice loop.
|
||||
- 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.
|
||||
|
||||
---
|
||||
*State initialized: 2026-04-26.*
|
||||
|
||||
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.
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -18,8 +19,9 @@ func NewServer(cfg config.Config) *http.Server {
|
||||
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)
|
||||
handler := httpapi.NewHandler(cfg, service, memory, progress, onto)
|
||||
handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
|
||||
@@ -6,6 +6,7 @@ const (
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultEnvironment = "development"
|
||||
defaultModelKey = "deepseek-v4-flash"
|
||||
defaultImageModelKey = "gpt-image-v2"
|
||||
defaultThirdOneBin = "thirdone"
|
||||
defaultWorkflowRuntime = ""
|
||||
)
|
||||
@@ -15,6 +16,7 @@ type Config struct {
|
||||
Environment string
|
||||
WorkflowRuntime string
|
||||
ModelKey string
|
||||
ImageModelKey string
|
||||
ThirdOneBin string
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ func LoadFromEnv() Config {
|
||||
Environment: envOrDefault("TUTOR_ENV", defaultEnvironment),
|
||||
WorkflowRuntime: envOrDefault("TUTOR_WORKFLOW_RUNTIME", defaultWorkflowRuntime),
|
||||
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
|
||||
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
|
||||
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ func TestLoadFromEnvDefaults(t *testing.T) {
|
||||
t.Setenv("TUTOR_ENV", "")
|
||||
t.Setenv("TUTOR_WORKFLOW_RUNTIME", "")
|
||||
t.Setenv("TUTOR_MODEL_KEY", "")
|
||||
t.Setenv("TUTOR_IMAGE_MODEL_KEY", "")
|
||||
t.Setenv("THIRDONE_BIN", "")
|
||||
|
||||
cfg := LoadFromEnv()
|
||||
@@ -20,6 +21,9 @@ func TestLoadFromEnvDefaults(t *testing.T) {
|
||||
if 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 {
|
||||
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_WORKFLOW_RUNTIME", "runtime.yaml")
|
||||
t.Setenv("TUTOR_MODEL_KEY", "other-model")
|
||||
t.Setenv("TUTOR_IMAGE_MODEL_KEY", "other-image-model")
|
||||
t.Setenv("THIRDONE_BIN", "C:/bin/thirdone.exe")
|
||||
|
||||
cfg := LoadFromEnv()
|
||||
@@ -46,6 +51,9 @@ func TestLoadFromEnvOverrides(t *testing.T) {
|
||||
if cfg.ModelKey != "other-model" {
|
||||
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" {
|
||||
t.Fatalf("ThirdOneBin = %q", cfg.ThirdOneBin)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,8 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
|
||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||
progress := progression.NewService(memory)
|
||||
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory, progress, onto)
|
||||
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()
|
||||
|
||||
createBody := bytes.NewBufferString(`{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -17,6 +18,7 @@ type Handler struct {
|
||||
memory *learnermemory.Service
|
||||
progress *progression.Service
|
||||
ontology *ontology.Service
|
||||
assets *teachingassets.Service
|
||||
}
|
||||
|
||||
func NewHandler(
|
||||
@@ -25,6 +27,7 @@ func NewHandler(
|
||||
memory *learnermemory.Service,
|
||||
progress *progression.Service,
|
||||
ontology *ontology.Service,
|
||||
assets *teachingassets.Service,
|
||||
) Handler {
|
||||
return Handler{
|
||||
cfg: cfg,
|
||||
@@ -32,6 +35,7 @@ func NewHandler(
|
||||
memory: memory,
|
||||
progress: progress,
|
||||
ontology: ontology,
|
||||
assets: assets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +50,8 @@ func (h Handler) Routes() http.Handler {
|
||||
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)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ func TestHealth(t *testing.T) {
|
||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||
progress := progression.NewService(memory)
|
||||
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||
handler := NewHandler(cfg, service, memory, progress, onto)
|
||||
assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey)
|
||||
handler := NewHandler(cfg, service, memory, progress, onto, assets)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/ontology"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/teachingassets"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,8 @@ func TestOntologyHTTPFlow(t *testing.T) {
|
||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||
progress := progression.NewService(memory)
|
||||
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||
handler := NewHandler(config.Config{Environment: "test"}, service, memory, progress, onto)
|
||||
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(`{
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
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"`
|
||||
}
|
||||
@@ -192,3 +192,52 @@ const (
|
||||
UnlockReviewCard UnlockKind = "review_card"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
- [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.
|
||||
|
||||
Reference in New Issue
Block a user