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

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

View File

@@ -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),
}
}

View File

@@ -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)
}

View File

@@ -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(`{

View File

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

View File

@@ -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()

View File

@@ -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(`{

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"
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"
)