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<