feat: add teaching asset prompts
This commit is contained in:
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user