- 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
557 lines
16 KiB
Go
557 lines
16 KiB
Go
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] + "..."
|
||
}
|