feat: CI/CD pipeline + Learning/Medicine/Travel services
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped

- 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:
home
2026-03-02 20:25:44 +03:00
parent 08bd41e75c
commit ab48a0632b
92 changed files with 15562 additions and 2198 deletions

View 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] + "..."
}

View 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)
}
}

View 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"
}

View 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] + "..."
}

View 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)
}
}

View 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 "{}"
}

View 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"])
}
}

View File

@@ -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) {