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

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