From 156daa90876d0a0225f4d645209f2f340a6c0bd0 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Apr 2026 17:54:23 +0900 Subject: [PATCH] feat: add teaching asset prompts --- .planning/REQUIREMENTS.md | 10 +- .planning/STATE.md | 15 ++- .../phases/006-teaching-assets/006-CONTEXT.md | 39 ++++++++ .../phases/006-teaching-assets/006-PLAN.md | 44 +++++++++ .../006-teaching-assets/006-RESEARCH.md | 21 ++++ .../phases/006-teaching-assets/006-SUMMARY.md | 38 ++++++++ .../006-teaching-assets/006-VERIFICATION.md | 29 ++++++ internal/app/server.go | 4 +- internal/config/config.go | 3 + internal/config/config_test.go | 8 ++ internal/httpapi/diagnostic_test.go | 4 +- internal/httpapi/handler.go | 6 ++ internal/httpapi/handler_test.go | 4 +- internal/httpapi/ontology_test.go | 4 +- internal/httpapi/teaching_assets.go | 47 +++++++++ internal/httpapi/teaching_assets_test.go | 64 +++++++++++++ internal/teachingassets/service.go | 96 +++++++++++++++++++ internal/teachingassets/service_test.go | 39 ++++++++ internal/teachingassets/store.go | 49 ++++++++++ internal/teachingassets/types.go | 34 +++++++ internal/workflows/contracts.go | 49 ++++++++++ .../bootstrap-job-tutor-platform/tasks.md | 1 + 22 files changed, 594 insertions(+), 14 deletions(-) create mode 100644 .planning/phases/006-teaching-assets/006-CONTEXT.md create mode 100644 .planning/phases/006-teaching-assets/006-PLAN.md create mode 100644 .planning/phases/006-teaching-assets/006-RESEARCH.md create mode 100644 .planning/phases/006-teaching-assets/006-SUMMARY.md create mode 100644 .planning/phases/006-teaching-assets/006-VERIFICATION.md create mode 100644 internal/httpapi/teaching_assets.go create mode 100644 internal/httpapi/teaching_assets_test.go create mode 100644 internal/teachingassets/service.go create mode 100644 internal/teachingassets/service_test.go create mode 100644 internal/teachingassets/store.go create mode 100644 internal/teachingassets/types.go diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index d5e0a04..52a8a62 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -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.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 7046abe..46d5076 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26) **Core value:** The user should feel and prove that they are becoming more interview-ready after each short practice loop. -**Current focus:** Phase 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.* diff --git a/.planning/phases/006-teaching-assets/006-CONTEXT.md b/.planning/phases/006-teaching-assets/006-CONTEXT.md new file mode 100644 index 0000000..0aa82f6 --- /dev/null +++ b/.planning/phases/006-teaching-assets/006-CONTEXT.md @@ -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. diff --git a/.planning/phases/006-teaching-assets/006-PLAN.md b/.planning/phases/006-teaching-assets/006-PLAN.md new file mode 100644 index 0000000..29bf4a1 --- /dev/null +++ b/.planning/phases/006-teaching-assets/006-PLAN.md @@ -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. diff --git a/.planning/phases/006-teaching-assets/006-RESEARCH.md b/.planning/phases/006-teaching-assets/006-RESEARCH.md new file mode 100644 index 0000000..47c5532 --- /dev/null +++ b/.planning/phases/006-teaching-assets/006-RESEARCH.md @@ -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. diff --git a/.planning/phases/006-teaching-assets/006-SUMMARY.md b/.planning/phases/006-teaching-assets/006-SUMMARY.md new file mode 100644 index 0000000..5022896 --- /dev/null +++ b/.planning/phases/006-teaching-assets/006-SUMMARY.md @@ -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. diff --git a/.planning/phases/006-teaching-assets/006-VERIFICATION.md b/.planning/phases/006-teaching-assets/006-VERIFICATION.md new file mode 100644 index 0000000..a9a7e8f --- /dev/null +++ b/.planning/phases/006-teaching-assets/006-VERIFICATION.md @@ -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. diff --git a/internal/app/server.go b/internal/app/server.go index 9831187..7b9830f 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index e251715..7fe2697 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ac435e8..60e197f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } diff --git a/internal/httpapi/diagnostic_test.go b/internal/httpapi/diagnostic_test.go index adc71f7..666aa26 100644 --- a/internal/httpapi/diagnostic_test.go +++ b/internal/httpapi/diagnostic_test.go @@ -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(`{ diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go index 30f1600..cc57251 100644 --- a/internal/httpapi/handler.go +++ b/internal/httpapi/handler.go @@ -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 } diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go index 6043442..026b42b 100644 --- a/internal/httpapi/handler_test.go +++ b/internal/httpapi/handler_test.go @@ -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() diff --git a/internal/httpapi/ontology_test.go b/internal/httpapi/ontology_test.go index 193354e..4792876 100644 --- a/internal/httpapi/ontology_test.go +++ b/internal/httpapi/ontology_test.go @@ -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(`{ diff --git a/internal/httpapi/teaching_assets.go b/internal/httpapi/teaching_assets.go new file mode 100644 index 0000000..e548806 --- /dev/null +++ b/internal/httpapi/teaching_assets.go @@ -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()) +} diff --git a/internal/httpapi/teaching_assets_test.go b/internal/httpapi/teaching_assets_test.go new file mode 100644 index 0000000..9b8fa9a --- /dev/null +++ b/internal/httpapi/teaching_assets_test.go @@ -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()) + } +} diff --git a/internal/teachingassets/service.go b/internal/teachingassets/service.go new file mode 100644 index 0000000..c52e8f2 --- /dev/null +++ b/internal/teachingassets/service.go @@ -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)) +} diff --git a/internal/teachingassets/service_test.go b/internal/teachingassets/service_test.go new file mode 100644 index 0000000..cc3622a --- /dev/null +++ b/internal/teachingassets/service_test.go @@ -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") + } +} diff --git a/internal/teachingassets/store.go b/internal/teachingassets/store.go new file mode 100644 index 0000000..05faa8d --- /dev/null +++ b/internal/teachingassets/store.go @@ -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 +} diff --git a/internal/teachingassets/types.go b/internal/teachingassets/types.go new file mode 100644 index 0000000..6b9500f --- /dev/null +++ b/internal/teachingassets/types.go @@ -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"` +} diff --git a/internal/workflows/contracts.go b/internal/workflows/contracts.go index 46d7a2b..8bae510 100644 --- a/internal/workflows/contracts.go +++ b/internal/workflows/contracts.go @@ -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" +) diff --git a/openspec/changes/bootstrap-job-tutor-platform/tasks.md b/openspec/changes/bootstrap-job-tutor-platform/tasks.md index 0ea6edc..5b595ea 100644 --- a/openspec/changes/bootstrap-job-tutor-platform/tasks.md +++ b/openspec/changes/bootstrap-job-tutor-platform/tasks.md @@ -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.