feat: add teaching asset prompts

This commit is contained in:
user
2026-04-26 17:54:23 +09:00
parent 4936cdf4c9
commit 156daa9087
22 changed files with 594 additions and 14 deletions

View File

@@ -59,11 +59,11 @@ interview-ready after each short practice loop.
### 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
@@ -99,7 +99,7 @@ interview-ready after each short practice loop.
| MEM-01..MEM-05 | Phase 3 | Complete | | MEM-01..MEM-05 | Phase 3 | Complete |
| PROG-01..PROG-05 | Phase 4 | Complete | | PROG-01..PROG-05 | Phase 4 | Complete |
| ONTO-01..ONTO-04 | Phase 5 | Complete | | ONTO-01..ONTO-04 | Phase 5 | Complete |
| ASSET-01..ASSET-03 | Phase 6 | Pending | | ASSET-01..ASSET-03 | Phase 6 | Complete |
**Coverage:** **Coverage:**
- v1 requirements: 28 total - v1 requirements: 28 total
@@ -108,4 +108,4 @@ interview-ready after each short practice loop.
--- ---
*Requirements defined: 2026-04-26* *Requirements defined: 2026-04-26*
*Last updated: 2026-04-26 after Phase 5 execution.* *Last updated: 2026-04-26 after Phase 6 execution.*

View File

@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
**Core value:** The user should feel and prove that they are becoming more **Core value:** The user should feel and prove that they are becoming more
interview-ready after each short practice loop. interview-ready after each short practice loop.
**Current focus:** Phase 6 planning: Teaching Assets. **Current focus:** v1 baseline complete; ready for milestone audit or frontend planning.
## Current Decisions ## Current Decisions
@@ -31,13 +31,15 @@ interview-ready after each short practice loop.
challenge APIs derived from learner memory evidence. challenge APIs derived from learner memory evidence.
- Phase 5 ontology material ingestion is implemented and verified with - Phase 5 ontology material ingestion is implemented and verified with
source-backed candidate concepts, prerequisite edges, and candidate gaps. 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 ## Next Actions
1. Plan Phase 6 teaching asset prompt generation with GSD. 1. Run a milestone audit across Phase 1 through Phase 6.
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during 2. Choose the next milestone: frontend MVP, persistence, real workflow runtime,
future workflow implementation. or document parser integration.
3. Verify the production OpenAI image model identifier before real asset 3. Verify the production OpenAI image model identifier before real image
generation calls. generation calls.
## Validation Log ## Validation Log
@@ -61,6 +63,9 @@ interview-ready after each short practice loop.
- 2026-04-26: Phase 5 implementation verified with `go test ./...`, - 2026-04-26: Phase 5 implementation verified with `go test ./...`,
`openspec validate bootstrap-job-tutor-platform --strict`, live material `openspec validate bootstrap-job-tutor-platform --strict`, live material
ingestion and ontology snapshot smoke, and Go source line-count check. 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.* *State initialized: 2026-04-26.*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -18,8 +19,9 @@ func NewServer(cfg config.Config) *http.Server {
memory := learnermemory.NewService(learnermemory.NewMemoryStore()) memory := learnermemory.NewService(learnermemory.NewMemoryStore())
progress := progression.NewService(memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore()) 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, progress, onto) handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
return &http.Server{ return &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import (
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -20,7 +21,8 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore()) 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() routes := handler.Routes()
createBody := bytes.NewBufferString(`{ createBody := bytes.NewBufferString(`{

View File

@@ -9,6 +9,7 @@ import (
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets"
) )
type Handler struct { type Handler struct {
@@ -17,6 +18,7 @@ type Handler struct {
memory *learnermemory.Service memory *learnermemory.Service
progress *progression.Service progress *progression.Service
ontology *ontology.Service ontology *ontology.Service
assets *teachingassets.Service
} }
func NewHandler( func NewHandler(
@@ -25,6 +27,7 @@ func NewHandler(
memory *learnermemory.Service, memory *learnermemory.Service,
progress *progression.Service, progress *progression.Service,
ontology *ontology.Service, ontology *ontology.Service,
assets *teachingassets.Service,
) Handler { ) Handler {
return Handler{ return Handler{
cfg: cfg, cfg: cfg,
@@ -32,6 +35,7 @@ func NewHandler(
memory: memory, memory: memory,
progress: progress, progress: progress,
ontology: ontology, 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("GET /api/v1/learners/{userID}/next-challenge", h.getNextChallenge)
mux.HandleFunc("POST /api/v1/materials", h.ingestMaterial) mux.HandleFunc("POST /api/v1/materials", h.ingestMaterial)
mux.HandleFunc("GET /api/v1/ontology", h.getOntology) 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 return mux
} }

View File

@@ -11,6 +11,7 @@ import (
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -23,7 +24,8 @@ func TestHealth(t *testing.T) {
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore()) 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) req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -12,6 +12,7 @@ import (
"tutor/internal/learnermemory" "tutor/internal/learnermemory"
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets"
"tutor/internal/workflows" "tutor/internal/workflows"
) )
@@ -20,7 +21,8 @@ func TestOntologyHTTPFlow(t *testing.T) {
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
progress := progression.NewService(memory) progress := progression.NewService(memory)
onto := ontology.NewService(ontology.NewMemoryStore()) 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() routes := handler.Routes()
body := bytes.NewBufferString(`{ body := bytes.NewBufferString(`{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,3 +16,4 @@
- [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] 13. Implement evidence-backed readiness map and next challenge APIs.
- [x] 14. Implement source-backed ontology material ingestion. - [x] 14. Implement source-backed ontology material ingestion.
- [x] 15. Implement source-backed teaching asset prompt candidates.