feat: CI/CD pipeline + Learning/Medicine/Travel services
- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
This commit is contained in:
556
backend/internal/learning/course_autogen.go
Normal file
556
backend/internal/learning/course_autogen.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
)
|
||||
|
||||
type CourseAutoGenConfig struct {
|
||||
LLM llm.Client
|
||||
Repo *db.LearningRepository
|
||||
SearchClient *search.SearXNGClient
|
||||
}
|
||||
|
||||
type CourseAutoGenerator struct {
|
||||
cfg CourseAutoGenConfig
|
||||
}
|
||||
|
||||
func NewCourseAutoGenerator(cfg CourseAutoGenConfig) *CourseAutoGenerator {
|
||||
return &CourseAutoGenerator{cfg: cfg}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) StartBackground(ctx context.Context) {
|
||||
log.Println("[course-autogen] starting background course generation")
|
||||
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(2 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
g.runCycle(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
g.runCycle(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) runCycle(ctx context.Context) {
|
||||
log.Println("[course-autogen] running generation cycle")
|
||||
|
||||
cycleCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := g.collectTrends(cycleCtx); err != nil {
|
||||
log.Printf("[course-autogen] trend collection error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
trend, err := g.cfg.Repo.PickTopTrend(cycleCtx)
|
||||
if err != nil || trend == nil {
|
||||
log.Printf("[course-autogen] no more trends to process")
|
||||
break
|
||||
}
|
||||
|
||||
if err := g.designAndPublishCourse(cycleCtx, trend); err != nil {
|
||||
log.Printf("[course-autogen] course design error for '%s': %v", trend.Topic, err)
|
||||
continue
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) collectTrends(ctx context.Context) error {
|
||||
var webContext string
|
||||
|
||||
if g.cfg.SearchClient != nil {
|
||||
webContext = g.searchTrendData(ctx)
|
||||
}
|
||||
|
||||
prompt := `Ты — аналитик трендов IT-индустрии и образования в России и мире.`
|
||||
|
||||
if webContext != "" {
|
||||
prompt += "\n\nРЕАЛЬНЫЕ ДАННЫЕ ИЗ ИНТЕРНЕТА:\n" + webContext
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
На основе реальных данных выбери 5 уникальных тем для курсов:
|
||||
|
||||
КРИТЕРИИ:
|
||||
1. Актуальны на рынке РФ (вакансии hh.ru, habr, стеки)
|
||||
2. НЕ банальные ("Основы Python", "HTML для начинающих" — НЕТ)
|
||||
3. Практическая ценность для карьеры и зарплаты
|
||||
4. Уникальность — чего нет на Stepik/Coursera/Skillbox
|
||||
5. Тренды 2025-2026: AI/ML ops, platform engineering, Rust, WebAssembly, edge computing и т.д.
|
||||
|
||||
Категории: programming, devops, data, ai_ml, security, product, design, management, fintech, gamedev, mobile, blockchain, iot, other
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"trends": [
|
||||
{
|
||||
"topic": "Конкретное название курса",
|
||||
"category": "категория",
|
||||
"why_unique": "Почему этот курс уникален и привлечёт пользователей",
|
||||
"demand_signals": ["сигнал спроса 1", "сигнал спроса 2"],
|
||||
"target_salary": "ожидаемая зарплата после курса",
|
||||
"score": 0.85
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
}, 2, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
var parsed struct {
|
||||
Trends []struct {
|
||||
Topic string `json:"topic"`
|
||||
Category string `json:"category"`
|
||||
WhyUnique string `json:"why_unique"`
|
||||
DemandSignals []string `json:"demand_signals"`
|
||||
TargetSalary string `json:"target_salary"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"trends"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil || len(parsed.Trends) == 0 {
|
||||
// Try a strict repair prompt once (common provider failure mode: extra prose / malformed JSON)
|
||||
repairPrompt := "Верни ответ СТРОГО как JSON без текста. " + prompt
|
||||
repaired, rerr := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: repairPrompt}},
|
||||
}, 1, 2*time.Second)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("failed to parse trends: %w", err)
|
||||
}
|
||||
jsonStr = extractJSONBlock(repaired)
|
||||
if uerr := json.Unmarshal([]byte(jsonStr), &parsed); uerr != nil || len(parsed.Trends) == 0 {
|
||||
if uerr != nil {
|
||||
return fmt.Errorf("failed to parse trends: %w", uerr)
|
||||
}
|
||||
return fmt.Errorf("failed to parse trends: empty trends")
|
||||
}
|
||||
}
|
||||
|
||||
saved := 0
|
||||
for _, t := range parsed.Trends {
|
||||
fp := generateFingerprint(t.Topic)
|
||||
|
||||
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
signals, _ := json.Marshal(map[string]interface{}{
|
||||
"why_unique": t.WhyUnique,
|
||||
"demand_signals": t.DemandSignals,
|
||||
"target_salary": t.TargetSalary,
|
||||
})
|
||||
|
||||
trend := &db.LearningTrendCandidate{
|
||||
Topic: t.Topic,
|
||||
Category: t.Category,
|
||||
Signals: signals,
|
||||
Score: t.Score,
|
||||
Fingerprint: fp,
|
||||
}
|
||||
if err := g.cfg.Repo.CreateTrend(ctx, trend); err == nil {
|
||||
saved++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[course-autogen] saved %d new trend candidates", saved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) searchTrendData(ctx context.Context) string {
|
||||
queries := []string{
|
||||
"IT тренды обучение 2025 2026 Россия",
|
||||
"самые востребованные IT навыки вакансии hh.ru",
|
||||
"новые технологии программирование курсы",
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
snippet := r.Title + ": " + r.Content
|
||||
if len(snippet) > 300 {
|
||||
snippet = snippet[:300]
|
||||
}
|
||||
results = append(results, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
combined := strings.Join(results, "\n---\n")
|
||||
if len(combined) > 3000 {
|
||||
combined = combined[:3000]
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) designAndPublishCourse(ctx context.Context, trend *db.LearningTrendCandidate) error {
|
||||
log.Printf("[course-autogen] designing course: %s", trend.Topic)
|
||||
|
||||
fp := generateFingerprint(trend.Topic)
|
||||
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var marketResearch string
|
||||
if g.cfg.SearchClient != nil {
|
||||
marketResearch = g.researchCourseTopic(ctx, trend.Topic)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT. Спроектируй профессиональный курс.
|
||||
|
||||
Тема: %s
|
||||
Категория: %s`, trend.Topic, trend.Category)
|
||||
|
||||
if marketResearch != "" {
|
||||
prompt += "\n\nИССЛЕДОВАНИЕ РЫНКА:\n" + marketResearch
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
ТРЕБОВАНИЯ:
|
||||
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
|
||||
2. Каждый модуль — практическое задание из реального проекта
|
||||
3. Уровень: от базового до продвинутого
|
||||
4. Курс должен быть уникальным — не копия Stepik/Coursera
|
||||
5. Лендинг должен ПРОДАВАТЬ — конкретные выгоды, зарплаты, результаты
|
||||
6. Outline должен быть детальным — 8-12 модулей
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"title": "Привлекательное название курса",
|
||||
"slug": "slug-without-spaces",
|
||||
"short_description": "Краткое описание 2-3 предложения. Конкретика, не вода.",
|
||||
"difficulty": "beginner|intermediate|advanced",
|
||||
"duration_hours": 40,
|
||||
"tags": ["тег1", "тег2"],
|
||||
"outline": {
|
||||
"modules": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Название модуля",
|
||||
"description": "Описание + что делаем на практике",
|
||||
"skills": ["навык"],
|
||||
"estimated_hours": 4,
|
||||
"practice_focus": "Конкретная практическая задача"
|
||||
}
|
||||
]
|
||||
},
|
||||
"landing": {
|
||||
"hero_title": "Заголовок лендинга (продающий)",
|
||||
"hero_subtitle": "Подзаголовок с конкретной выгодой",
|
||||
"benefits": ["Конкретная выгода 1", "Выгода 2", "Выгода 3", "Выгода 4"],
|
||||
"target_audience": "Для кого этот курс — конкретно",
|
||||
"outcomes": ["Результат 1 с цифрами", "Результат 2"],
|
||||
"salary_range": "Ожидаемая зарплата после курса",
|
||||
"prerequisites": "Что нужно знать заранее",
|
||||
"faq": [
|
||||
{"question": "Вопрос?", "answer": "Ответ"}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
}, 2, 2*time.Second)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
var parsed struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
ShortDescription string `json:"short_description"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
DurationHours int `json:"duration_hours"`
|
||||
Tags []string `json:"tags"`
|
||||
Outline json.RawMessage `json:"outline"`
|
||||
Landing json.RawMessage `json:"landing"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
lastErr = fmt.Errorf("failed to parse course design: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
outlineJSON := parsed.Outline
|
||||
if outlineJSON == nil {
|
||||
outlineJSON = json.RawMessage("{}")
|
||||
}
|
||||
landingJSON := parsed.Landing
|
||||
if landingJSON == nil {
|
||||
landingJSON = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
if err := validateCourseArtifacts(parsed.Title, parsed.ShortDescription, outlineJSON, landingJSON); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(parsed.Slug)
|
||||
if slug == "" {
|
||||
slug = sanitizeSlug(parsed.Title)
|
||||
}
|
||||
slug = g.ensureUniqueSlug(ctx, slug)
|
||||
|
||||
if parsed.DurationHours == 0 {
|
||||
parsed.DurationHours = 40
|
||||
}
|
||||
parsed.Difficulty = normalizeDifficulty(parsed.Difficulty)
|
||||
|
||||
course := &db.LearningCourse{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(parsed.Title),
|
||||
ShortDescription: strings.TrimSpace(parsed.ShortDescription),
|
||||
Category: trend.Category,
|
||||
Tags: parsed.Tags,
|
||||
Difficulty: parsed.Difficulty,
|
||||
DurationHours: parsed.DurationHours,
|
||||
BaseOutline: outlineJSON,
|
||||
Landing: landingJSON,
|
||||
Fingerprint: fp,
|
||||
Status: "published",
|
||||
}
|
||||
|
||||
if err := g.cfg.Repo.CreateCourse(ctx, course); err != nil {
|
||||
lastErr = fmt.Errorf("failed to save course: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[course-autogen] published course: %s (%s)", course.Title, course.Slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("unknown course design failure")
|
||||
}
|
||||
_ = g.cfg.Repo.MarkTrendFailed(ctx, trend.ID, truncateErr(lastErr.Error(), 800))
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) researchCourseTopic(ctx context.Context, topic string) string {
|
||||
queries := []string{
|
||||
topic + " курс программа обучение",
|
||||
topic + " вакансии зарплата Россия",
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
snippet := r.Title + ": " + r.Content
|
||||
if len(snippet) > 250 {
|
||||
snippet = snippet[:250]
|
||||
}
|
||||
results = append(results, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
combined := strings.Join(results, "\n---\n")
|
||||
if len(combined) > 2000 {
|
||||
combined = combined[:2000]
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func generateFingerprint(topic string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(topic))
|
||||
hash := sha256.Sum256([]byte(normalized))
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var result []rune
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
result = append(result, r)
|
||||
} else if r == ' ' || r == '-' || r == '_' {
|
||||
result = append(result, '-')
|
||||
}
|
||||
}
|
||||
slug := string(result)
|
||||
re := regexp.MustCompile(`-+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 100 {
|
||||
slug = slug[:100]
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) ensureUniqueSlug(ctx context.Context, base string) string {
|
||||
if base == "" {
|
||||
base = "course"
|
||||
}
|
||||
slug := base
|
||||
for i := 0; i < 20; i++ {
|
||||
exists, err := g.cfg.Repo.SlugExists(ctx, slug)
|
||||
if err == nil && !exists {
|
||||
return slug
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i+2)
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix()%10000)
|
||||
}
|
||||
|
||||
func normalizeDifficulty(d string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(d)) {
|
||||
case "beginner", "intermediate", "advanced":
|
||||
return strings.ToLower(strings.TrimSpace(d))
|
||||
default:
|
||||
return "intermediate"
|
||||
}
|
||||
}
|
||||
|
||||
func validateCourseArtifacts(title, short string, outlineJSON, landingJSON json.RawMessage) error {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return errors.New("course title is empty")
|
||||
}
|
||||
if len(strings.TrimSpace(short)) < 40 {
|
||||
return errors.New("short_description слишком короткое (нужна конкретика)")
|
||||
}
|
||||
|
||||
// Outline validation
|
||||
var outline struct {
|
||||
Modules []struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Skills []string `json:"skills"`
|
||||
EstimatedHours int `json:"estimated_hours"`
|
||||
PracticeFocus string `json:"practice_focus"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(outlineJSON, &outline); err != nil {
|
||||
return fmt.Errorf("outline JSON invalid: %w", err)
|
||||
}
|
||||
if len(outline.Modules) < 8 || len(outline.Modules) > 12 {
|
||||
return fmt.Errorf("outline modules count must be 8-12, got %d", len(outline.Modules))
|
||||
}
|
||||
for i, m := range outline.Modules {
|
||||
if strings.TrimSpace(m.Title) == "" || strings.TrimSpace(m.PracticeFocus) == "" {
|
||||
return fmt.Errorf("outline module[%d] missing title/practice_focus", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Landing validation
|
||||
var landing struct {
|
||||
HeroTitle string `json:"hero_title"`
|
||||
HeroSubtitle string `json:"hero_subtitle"`
|
||||
Benefits []string `json:"benefits"`
|
||||
Outcomes []string `json:"outcomes"`
|
||||
SalaryRange string `json:"salary_range"`
|
||||
FAQ []struct {
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
} `json:"faq"`
|
||||
}
|
||||
if err := json.Unmarshal(landingJSON, &landing); err != nil {
|
||||
return fmt.Errorf("landing JSON invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(landing.HeroTitle) == "" || strings.TrimSpace(landing.HeroSubtitle) == "" {
|
||||
return errors.New("landing missing hero_title/hero_subtitle")
|
||||
}
|
||||
if len(landing.Benefits) < 3 || len(landing.Outcomes) < 2 {
|
||||
return errors.New("landing benefits/outcomes недостаточно конкретные")
|
||||
}
|
||||
if strings.TrimSpace(landing.SalaryRange) == "" {
|
||||
return errors.New("landing missing salary_range")
|
||||
}
|
||||
if len(landing.FAQ) < 1 || strings.TrimSpace(landing.FAQ[0].Question) == "" {
|
||||
return errors.New("landing FAQ missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateTextWithRetry(ctx context.Context, client llm.Client, req llm.StreamRequest, retries int, baseDelay time.Duration) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt-1))
|
||||
t := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return "", ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
res, err := client.GenerateText(ctx, req)
|
||||
if err == nil && strings.TrimSpace(res) != "" {
|
||||
return res, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("empty response")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func truncateErr(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
79
backend/internal/learning/course_autogen_test.go
Normal file
79
backend/internal/learning/course_autogen_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestSanitizeSlug(t *testing.T) {
|
||||
got := sanitizeSlug(" Go Backend: Production _ Course ")
|
||||
if got != "go-backend-production-course" {
|
||||
t.Fatalf("sanitizeSlug result mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDifficulty(t *testing.T) {
|
||||
if normalizeDifficulty("advanced") != "advanced" {
|
||||
t.Fatalf("expected advanced difficulty")
|
||||
}
|
||||
if normalizeDifficulty("unknown") != "intermediate" {
|
||||
t.Fatalf("expected fallback to intermediate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCourseArtifacts(t *testing.T) {
|
||||
outline := json.RawMessage(`{
|
||||
"modules": [
|
||||
{"index":0,"title":"m1","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":1,"title":"m2","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":2,"title":"m3","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":3,"title":"m4","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":4,"title":"m5","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":5,"title":"m6","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":6,"title":"m7","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":7,"title":"m8","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"}
|
||||
]
|
||||
}`)
|
||||
landing := json.RawMessage(`{
|
||||
"hero_title":"Hero",
|
||||
"hero_subtitle":"Subtitle",
|
||||
"benefits":["b1","b2","b3"],
|
||||
"outcomes":["o1","o2"],
|
||||
"salary_range":"200k",
|
||||
"faq":[{"question":"q","answer":"a"}]
|
||||
}`)
|
||||
|
||||
err := validateCourseArtifacts("Course", "Это достаточно длинное описание для валидации курса в тесте.", outline, landing)
|
||||
if err != nil {
|
||||
t.Fatalf("validateCourseArtifacts unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextWithRetry(t *testing.T) {
|
||||
attempt := 0
|
||||
client := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
attempt++
|
||||
if attempt < 3 {
|
||||
return "", errors.New("temporary")
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
}
|
||||
|
||||
got, err := generateTextWithRetry(context.Background(), client, llm.StreamRequest{}, 3, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("generateTextWithRetry error: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("unexpected result: %q", got)
|
||||
}
|
||||
if attempt != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", attempt)
|
||||
}
|
||||
}
|
||||
36
backend/internal/learning/mock_llm_test.go
Normal file
36
backend/internal/learning/mock_llm_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type mockLLMClient struct {
|
||||
generateFunc func(ctx context.Context, req llm.StreamRequest) (string, error)
|
||||
streamFunc func(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error)
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) StreamText(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error) {
|
||||
if m.streamFunc != nil {
|
||||
return m.streamFunc(ctx, req)
|
||||
}
|
||||
ch := make(chan llm.StreamChunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GenerateText(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
if m.generateFunc != nil {
|
||||
return m.generateFunc(ctx, req)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GetProviderID() string {
|
||||
return "mock"
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GetModelKey() string {
|
||||
return "mock-model"
|
||||
}
|
||||
118
backend/internal/learning/plan_builder.go
Normal file
118
backend/internal/learning/plan_builder.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type PersonalPlan struct {
|
||||
Modules []PlanModule `json:"modules"`
|
||||
TotalHours int `json:"total_hours"`
|
||||
DifficultyAdjusted string `json:"difficulty_adjusted"`
|
||||
PersonalizationNote string `json:"personalization_notes"`
|
||||
MilestoneProject string `json:"milestone_project"`
|
||||
}
|
||||
|
||||
type PlanModule struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Skills []string `json:"skills"`
|
||||
EstimatedHrs int `json:"estimated_hours"`
|
||||
PracticeFocus string `json:"practice_focus"`
|
||||
TaskCount int `json:"task_count"`
|
||||
IsCheckpoint bool `json:"is_checkpoint"`
|
||||
}
|
||||
|
||||
func BuildPersonalPlan(ctx context.Context, llmClient llm.Client, course *db.LearningCourse, profileJSON string) (json.RawMessage, error) {
|
||||
profileInfo := "Профиль неизвестен — план по умолчанию."
|
||||
if profileJSON != "" && profileJSON != "{}" {
|
||||
profileInfo = "Профиль ученика:\n" + truncateStr(profileJSON, 2000)
|
||||
}
|
||||
|
||||
outlineStr := string(course.BaseOutline)
|
||||
if len(outlineStr) > 4000 {
|
||||
outlineStr = outlineStr[:4000]
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT с 10-летним опытом. Адаптируй базовый план курса под конкретного ученика.
|
||||
|
||||
Курс: %s
|
||||
Описание: %s
|
||||
Сложность: %s
|
||||
Длительность: %d часов
|
||||
|
||||
Базовый план:
|
||||
%s
|
||||
|
||||
%s
|
||||
|
||||
ТРЕБОВАНИЯ:
|
||||
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
|
||||
2. Каждый модуль = конкретное практическое задание из реального проекта
|
||||
3. Прогрессия: от простого к сложному, учитывая текущий уровень ученика
|
||||
4. Каждый 3-й модуль — checkpoint (мини-проект для проверки навыков)
|
||||
5. Финальный milestone project — полноценный проект для портфолио
|
||||
6. Учитывай стек и опыт ученика — не повторяй то, что он уже знает
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Название модуля",
|
||||
"description": "Что изучаем и делаем",
|
||||
"skills": ["навык1"],
|
||||
"estimated_hours": 4,
|
||||
"practice_focus": "Конкретная практическая задача из реального проекта",
|
||||
"task_count": 3,
|
||||
"is_checkpoint": false
|
||||
}
|
||||
],
|
||||
"total_hours": 40,
|
||||
"difficulty_adjusted": "intermediate",
|
||||
"personalization_notes": "Как план адаптирован под ученика",
|
||||
"milestone_project": "Описание финального проекта для портфолио"
|
||||
}`, course.Title, course.ShortDescription, course.Difficulty, course.DurationHours, outlineStr, profileInfo)
|
||||
|
||||
var plan PersonalPlan
|
||||
if err := generateAndParse(ctx, llmClient, prompt, &plan, 2); err != nil {
|
||||
return course.BaseOutline, fmt.Errorf("plan generation failed, using base outline: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Modules) == 0 {
|
||||
return course.BaseOutline, nil
|
||||
}
|
||||
|
||||
for i := range plan.Modules {
|
||||
plan.Modules[i].Index = i
|
||||
if plan.Modules[i].TaskCount == 0 {
|
||||
plan.Modules[i].TaskCount = 2
|
||||
}
|
||||
}
|
||||
|
||||
if plan.TotalHours == 0 {
|
||||
total := 0
|
||||
for _, m := range plan.Modules {
|
||||
total += m.EstimatedHrs
|
||||
}
|
||||
plan.TotalHours = total
|
||||
}
|
||||
|
||||
result, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return course.BaseOutline, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
66
backend/internal/learning/plan_builder_test.go
Normal file
66
backend/internal/learning/plan_builder_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestBuildPersonalPlanAppliesDefaults(t *testing.T) {
|
||||
course := &db.LearningCourse{
|
||||
Title: "Go Backend",
|
||||
ShortDescription: "Курс по backend-разработке на Go",
|
||||
Difficulty: "intermediate",
|
||||
DurationHours: 24,
|
||||
BaseOutline: json.RawMessage(`{"modules":[{"index":0,"title":"base","description":"base","skills":["go"],"estimated_hours":4,"practice_focus":"api"}]}`),
|
||||
}
|
||||
|
||||
client := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
return `{
|
||||
"modules": [
|
||||
{"index": 999, "title": "API design", "description": "design REST", "skills": ["http"], "estimated_hours": 6, "practice_focus": "build handlers", "task_count": 0},
|
||||
{"index": 999, "title": "DB layer", "description": "storage", "skills": ["sql"], "estimated_hours": 8, "practice_focus": "repository pattern", "task_count": 3}
|
||||
],
|
||||
"total_hours": 0,
|
||||
"difficulty_adjusted": "intermediate",
|
||||
"personalization_notes": "adapted"
|
||||
}`, nil
|
||||
},
|
||||
}
|
||||
|
||||
planJSON, err := BuildPersonalPlan(context.Background(), client, course, `{"level":"junior"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPersonalPlan error: %v", err)
|
||||
}
|
||||
|
||||
var plan PersonalPlan
|
||||
if err := json.Unmarshal(planJSON, &plan); err != nil {
|
||||
t.Fatalf("unmarshal plan: %v", err)
|
||||
}
|
||||
|
||||
if len(plan.Modules) != 2 {
|
||||
t.Fatalf("expected 2 modules, got %d", len(plan.Modules))
|
||||
}
|
||||
if plan.Modules[0].Index != 0 || plan.Modules[1].Index != 1 {
|
||||
t.Fatalf("module indexes were not normalized: %+v", plan.Modules)
|
||||
}
|
||||
if plan.Modules[0].TaskCount != 2 {
|
||||
t.Fatalf("expected default task_count=2, got %d", plan.Modules[0].TaskCount)
|
||||
}
|
||||
if plan.TotalHours != 14 {
|
||||
t.Fatalf("expected total_hours=14, got %d", plan.TotalHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
if got := truncateStr("abc", 5); got != "abc" {
|
||||
t.Fatalf("truncateStr should keep short string, got %q", got)
|
||||
}
|
||||
if got := truncateStr("abcdef", 3); got != "abc..." {
|
||||
t.Fatalf("truncateStr should truncate with ellipsis, got %q", got)
|
||||
}
|
||||
}
|
||||
203
backend/internal/learning/profile_builder.go
Normal file
203
backend/internal/learning/profile_builder.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type UserProfile struct {
|
||||
Name string `json:"name"`
|
||||
ExperienceYears float64 `json:"experience_years"`
|
||||
CurrentRole string `json:"current_role"`
|
||||
Skills []string `json:"skills"`
|
||||
ProgrammingLangs []string `json:"programming_languages"`
|
||||
Frameworks []string `json:"frameworks"`
|
||||
Education string `json:"education"`
|
||||
Industries []string `json:"industries"`
|
||||
Strengths []string `json:"strengths"`
|
||||
GrowthAreas []string `json:"growth_areas"`
|
||||
CareerGoals string `json:"career_goals"`
|
||||
RecommendedTracks []string `json:"recommended_tracks"`
|
||||
Level string `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
func BuildProfileFromResume(ctx context.Context, llmClient llm.Client, extractedText string) (json.RawMessage, error) {
|
||||
if strings.TrimSpace(extractedText) == "" {
|
||||
return json.RawMessage("{}"), fmt.Errorf("empty resume text")
|
||||
}
|
||||
|
||||
if len(extractedText) > 12000 {
|
||||
extractedText = extractedText[:12000]
|
||||
}
|
||||
|
||||
prompt := `Ты — senior HR-аналитик с 15-летним опытом в IT-рекрутинге в РФ. Проанализируй резюме и создай детальный профиль.
|
||||
|
||||
Резюме:
|
||||
` + extractedText + `
|
||||
|
||||
ЗАДАЧА: Извлеки максимум информации. Определи реальный уровень кандидата (не завышай).
|
||||
|
||||
Ответь строго JSON (без markdown, без комментариев):
|
||||
{
|
||||
"name": "Имя Фамилия",
|
||||
"experience_years": 3.5,
|
||||
"current_role": "текущая должность или последняя",
|
||||
"skills": ["навык1", "навык2", "навык3"],
|
||||
"programming_languages": ["Go", "Python"],
|
||||
"frameworks": ["React", "Fiber"],
|
||||
"education": "образование кратко",
|
||||
"industries": ["fintech", "ecommerce"],
|
||||
"strengths": ["сильная сторона 1", "сильная сторона 2"],
|
||||
"growth_areas": ["зона роста 1", "зона роста 2"],
|
||||
"career_goals": "предположительные цели на основе опыта",
|
||||
"recommended_tracks": ["рекомендуемый трек 1", "трек 2"],
|
||||
"level": "junior|middle|senior|lead|expert",
|
||||
"summary": "Краткая характеристика кандидата в 2-3 предложения"
|
||||
}`
|
||||
|
||||
var profile UserProfile
|
||||
err := generateAndParse(ctx, llmClient, prompt, &profile, 2)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}"), fmt.Errorf("profile extraction failed: %w", err)
|
||||
}
|
||||
|
||||
if profile.Level == "" {
|
||||
profile.Level = inferLevel(profile.ExperienceYears)
|
||||
}
|
||||
if profile.Summary == "" {
|
||||
profile.Summary = fmt.Sprintf("%s, %s, опыт %.0f лет", profile.Name, profile.CurrentRole, profile.ExperienceYears)
|
||||
}
|
||||
|
||||
result, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}"), err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildProfileFromOnboarding(ctx context.Context, llmClient llm.Client, answers map[string]string) (json.RawMessage, error) {
|
||||
answersJSON, _ := json.Marshal(answers)
|
||||
|
||||
prompt := `Ты — методолог обучения. На основе ответов пользователя на онбординг-вопросы, построй профиль.
|
||||
|
||||
Ответы пользователя:
|
||||
` + string(answersJSON) + `
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"name": "",
|
||||
"experience_years": 0,
|
||||
"current_role": "",
|
||||
"skills": [],
|
||||
"programming_languages": [],
|
||||
"frameworks": [],
|
||||
"education": "",
|
||||
"industries": [],
|
||||
"strengths": [],
|
||||
"growth_areas": [],
|
||||
"career_goals": "",
|
||||
"recommended_tracks": [],
|
||||
"level": "beginner|junior|middle|senior",
|
||||
"summary": "Краткая характеристика"
|
||||
}`
|
||||
|
||||
var profile UserProfile
|
||||
if err := generateAndParse(ctx, llmClient, prompt, &profile, 2); err != nil {
|
||||
return json.RawMessage("{}"), err
|
||||
}
|
||||
|
||||
if profile.Level == "" {
|
||||
profile.Level = "beginner"
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(profile)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inferLevel(years float64) string {
|
||||
switch {
|
||||
case years < 1:
|
||||
return "beginner"
|
||||
case years < 3:
|
||||
return "junior"
|
||||
case years < 5:
|
||||
return "middle"
|
||||
case years < 8:
|
||||
return "senior"
|
||||
default:
|
||||
return "lead"
|
||||
}
|
||||
}
|
||||
|
||||
func generateAndParse(ctx context.Context, llmClient llm.Client, prompt string, target interface{}, maxRetries int) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
result, err := llmClient.GenerateText(attemptCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d: JSON parse error: %w", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("all %d attempts failed: %w", maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func extractJSONBlock(text string) string {
|
||||
if strings.Contains(text, "```json") {
|
||||
start := strings.Index(text, "```json") + 7
|
||||
end := strings.Index(text[start:], "```")
|
||||
if end > 0 {
|
||||
return strings.TrimSpace(text[start : start+end])
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "```") {
|
||||
start := strings.Index(text, "```") + 3
|
||||
if nl := strings.Index(text[start:], "\n"); nl >= 0 {
|
||||
start += nl + 1
|
||||
}
|
||||
end := strings.Index(text[start:], "```")
|
||||
if end > 0 {
|
||||
candidate := strings.TrimSpace(text[start : start+end])
|
||||
if len(candidate) > 2 && candidate[0] == '{' {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depth := 0
|
||||
startIdx := -1
|
||||
for i, ch := range text {
|
||||
if ch == '{' {
|
||||
if depth == 0 {
|
||||
startIdx = i
|
||||
}
|
||||
depth++
|
||||
} else if ch == '}' {
|
||||
depth--
|
||||
if depth == 0 && startIdx >= 0 {
|
||||
return text[startIdx : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "{}"
|
||||
}
|
||||
65
backend/internal/learning/profile_builder_test.go
Normal file
65
backend/internal/learning/profile_builder_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestInferLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
years float64
|
||||
want string
|
||||
}{
|
||||
{0, "beginner"},
|
||||
{1.5, "junior"},
|
||||
{3.2, "middle"},
|
||||
{6.5, "senior"},
|
||||
{10, "lead"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := inferLevel(tc.years)
|
||||
if got != tc.want {
|
||||
t.Fatalf("inferLevel(%v) = %q, want %q", tc.years, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONBlockFromMarkdown(t *testing.T) {
|
||||
input := "text before\n```json\n{\"name\":\"Alex\",\"level\":\"junior\"}\n```\ntext after"
|
||||
got := extractJSONBlock(input)
|
||||
if got != "{\"name\":\"Alex\",\"level\":\"junior\"}" {
|
||||
t.Fatalf("unexpected json block: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileFromOnboarding(t *testing.T) {
|
||||
llmClient := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
return `{"name":"Иван","experience_years":1.5,"current_role":"qa","skills":["testing"],"programming_languages":["Go"],"frameworks":[],"education":"BS","industries":["it"],"strengths":["аналитика"],"growth_areas":["backend"],"career_goals":"backend","recommended_tracks":["backend go"],"level":"junior","summary":"Начинающий специалист"}`, nil
|
||||
},
|
||||
}
|
||||
|
||||
profileJSON, err := BuildProfileFromOnboarding(context.Background(), llmClient, map[string]string{
|
||||
"experience_level": "junior",
|
||||
"target_track": "backend go",
|
||||
"weekly_hours": "10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildProfileFromOnboarding error: %v", err)
|
||||
}
|
||||
|
||||
var profile map[string]interface{}
|
||||
if err := json.Unmarshal(profileJSON, &profile); err != nil {
|
||||
t.Fatalf("profile json unmarshal: %v", err)
|
||||
}
|
||||
if profile["name"] != "Иван" {
|
||||
t.Fatalf("unexpected name: %v", profile["name"])
|
||||
}
|
||||
if profile["level"] != "junior" {
|
||||
t.Fatalf("unexpected level: %v", profile["level"])
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
@@ -680,12 +679,7 @@ func (l *StepByStepLesson) SubmitQuizAnswer(stepIndex int, selectedOptions []str
|
||||
}
|
||||
|
||||
func extractJSON(text string) string {
|
||||
re := regexp.MustCompile(`(?s)\{.*\}`)
|
||||
match := re.FindString(text)
|
||||
if match != "" {
|
||||
return match
|
||||
}
|
||||
return "{}"
|
||||
return extractJSONBlock(text)
|
||||
}
|
||||
|
||||
func (l *StepByStepLesson) ToJSON() ([]byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user