package main import ( "context" "encoding/json" "fmt" "log" "os" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gooseek/backend/internal/db" "github.com/gooseek/backend/internal/learning" "github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/search" "github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/middleware" ) func main() { cfg, err := config.Load() if err != nil { log.Fatal("Failed to load config:", err) } var database *db.PostgresDB var repo *db.LearningRepository if cfg.DatabaseURL != "" { maxRetries := 30 for i := 0; i < maxRetries; i++ { database, err = db.NewPostgresDB(cfg.DatabaseURL) if err == nil { break } log.Printf("Waiting for database (attempt %d/%d): %v", i+1, maxRetries, err) time.Sleep(2 * time.Second) } if err != nil { log.Fatal("Database required for learning-svc:", err) } defer database.Close() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if err := database.RunMigrations(ctx); err != nil { log.Printf("Base migrations warning: %v", err) } repo = db.NewLearningRepository(database) if err := repo.RunMigrations(ctx); err != nil { log.Printf("Learning migrations warning: %v", err) } log.Println("PostgreSQL connected, learning migrations complete") } else { log.Fatal("DATABASE_URL required for learning-svc") } llmClient := createLLMClient(cfg) if llmClient == nil { log.Fatal("No LLM provider configured") } searchClient := search.NewSearXNGClient(cfg) courseGen := learning.NewCourseAutoGenerator(learning.CourseAutoGenConfig{ LLM: llmClient, Repo: repo, SearchClient: searchClient, }) go courseGen.StartBackground(context.Background()) app := fiber.New(fiber.Config{ BodyLimit: 50 * 1024 * 1024, ReadTimeout: 120 * time.Second, WriteTimeout: 120 * time.Second, }) app.Use(logger.New()) app.Use(cors.New()) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) }) api := app.Group("/api/v1/learning", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, AllowGuest: true, })) api.Get("/courses", func(c *fiber.Ctx) error { category := c.Query("category") difficulty := c.Query("difficulty") search := c.Query("search") limit := c.QueryInt("limit", 20) offset := c.QueryInt("offset", 0) courses, total, err := repo.ListCourses(c.Context(), category, difficulty, search, limit, offset) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to list courses"}) } return c.JSON(fiber.Map{"courses": courses, "total": total}) }) api.Get("/courses/:slug", func(c *fiber.Ctx) error { slug := c.Params("slug") course, err := repo.GetCourseBySlug(c.Context(), slug) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to get course"}) } if course == nil { return c.Status(404).JSON(fiber.Map{"error": "Course not found"}) } return c.JSON(course) }) api.Get("/me/profile", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } profile, err := repo.GetProfile(c.Context(), userID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to get profile"}) } if profile == nil { return c.JSON(fiber.Map{"profile": nil, "exists": false}) } return c.JSON(fiber.Map{"profile": profile, "exists": true}) }) api.Post("/me/profile", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } var req struct { DisplayName string `json:"displayName"` Profile json.RawMessage `json:"profile"` OnboardingCompleted bool `json:"onboardingCompleted"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } if req.Profile == nil { req.Profile = json.RawMessage("{}") } profile := &db.LearningUserProfile{ UserID: userID, DisplayName: req.DisplayName, Profile: req.Profile, OnboardingCompleted: req.OnboardingCompleted, } if err := repo.UpsertProfile(c.Context(), profile); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to save profile"}) } return c.JSON(fiber.Map{"success": true}) }) api.Post("/me/onboarding", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } var req struct { DisplayName string `json:"displayName"` Answers map[string]string `json:"answers"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } sanitizedAnswers := make(map[string]string, len(req.Answers)) for k, v := range req.Answers { key := strings.TrimSpace(k) val := strings.TrimSpace(v) if key == "" || val == "" { continue } if len(val) > 600 { val = val[:600] } sanitizedAnswers[key] = val } if len(sanitizedAnswers) < 3 { return c.Status(400).JSON(fiber.Map{"error": "At least 3 onboarding answers are required"}) } ctx, cancel := context.WithTimeout(c.Context(), 60*time.Second) defer cancel() profileJSON, err := learning.BuildProfileFromOnboarding(ctx, llmClient, sanitizedAnswers) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to build onboarding profile"}) } existingProfile, _ := repo.GetProfile(c.Context(), userID) profile := &db.LearningUserProfile{ UserID: userID, DisplayName: strings.TrimSpace(req.DisplayName), Profile: profileJSON, OnboardingCompleted: true, } if existingProfile != nil { if profile.DisplayName == "" { profile.DisplayName = existingProfile.DisplayName } profile.ResumeFileID = existingProfile.ResumeFileID profile.ResumeExtractedText = existingProfile.ResumeExtractedText } if err := repo.UpsertProfile(c.Context(), profile); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to save onboarding profile"}) } return c.JSON(fiber.Map{"success": true, "profile": profileJSON}) }) api.Post("/me/resume", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } var req struct { FileID string `json:"fileId"` ExtractedText string `json:"extractedText"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } if req.ExtractedText == "" { return c.Status(400).JSON(fiber.Map{"error": "Extracted text required"}) } ctx, cancel := context.WithTimeout(c.Context(), 60*time.Second) defer cancel() profileJSON, err := learning.BuildProfileFromResume(ctx, llmClient, req.ExtractedText) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to build profile from resume"}) } profile := &db.LearningUserProfile{ UserID: userID, Profile: profileJSON, ResumeFileID: &req.FileID, ResumeExtractedText: req.ExtractedText, OnboardingCompleted: true, } if err := repo.UpsertProfile(c.Context(), profile); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to save profile"}) } return c.JSON(fiber.Map{"success": true, "profile": profileJSON}) }) api.Post("/enroll", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } var req struct { CourseID string `json:"courseId"` Slug string `json:"slug"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } var course *db.LearningCourse var courseErr error if req.CourseID != "" { course, courseErr = repo.GetCourseByID(c.Context(), req.CourseID) } else if req.Slug != "" { course, courseErr = repo.GetCourseBySlug(c.Context(), req.Slug) } else { return c.Status(400).JSON(fiber.Map{"error": "courseId or slug required"}) } if courseErr != nil || course == nil { return c.Status(404).JSON(fiber.Map{"error": "Course not found"}) } ctx, cancel := context.WithTimeout(c.Context(), 90*time.Second) defer cancel() profile, _ := repo.GetProfile(ctx, userID) var profileText string if profile != nil { profileText = string(profile.Profile) } plan, err := learning.BuildPersonalPlan(ctx, llmClient, course, profileText) if err != nil { plan = course.BaseOutline } enrollment := &db.LearningEnrollment{ UserID: userID, CourseID: course.ID, Status: "active", Plan: plan, Progress: json.RawMessage(`{"completed_modules":[],"current_module":0,"score":0}`), } if err := repo.CreateEnrollment(c.Context(), enrollment); err != nil { if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { return c.Status(409).JSON(fiber.Map{"error": "Already enrolled in this course"}) } return c.Status(500).JSON(fiber.Map{"error": "Failed to create enrollment"}) } return c.Status(201).JSON(enrollment) }) api.Get("/enrollments", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } enrollments, err := repo.ListEnrollments(c.Context(), userID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to list enrollments"}) } if enrollments == nil { enrollments = []*db.LearningEnrollment{} } return c.JSON(fiber.Map{"enrollments": enrollments}) }) api.Get("/enrollments/:id", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } enrollment, err := repo.GetEnrollment(c.Context(), c.Params("id")) if err != nil || enrollment == nil { return c.Status(404).JSON(fiber.Map{"error": "Enrollment not found"}) } if enrollment.UserID != userID { return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) } course, _ := repo.GetCourseByID(c.Context(), enrollment.CourseID) enrollment.Course = course tasks, _ := repo.ListTasksByEnrollment(c.Context(), enrollment.ID) if tasks == nil { tasks = []*db.LearningTask{} } return c.JSON(fiber.Map{"enrollment": enrollment, "tasks": tasks}) }) api.Get("/enrollments/:id/tasks", func(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } enrollment, err := repo.GetEnrollment(c.Context(), c.Params("id")) if err != nil || enrollment == nil || enrollment.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Not found"}) } tasks, err := repo.ListTasksByEnrollment(c.Context(), enrollment.ID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to list tasks"}) } if tasks == nil { tasks = []*db.LearningTask{} } return c.JSON(fiber.Map{"tasks": tasks}) }) port := getEnvInt("LEARNING_SVC_PORT", 3034) log.Printf("learning-svc listening on :%d", port) log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) } func createLLMClient(cfg *config.Config) llm.Client { if cfg.TimewebAgentAccessID != "" && cfg.TimewebAPIKey != "" { client, err := llm.NewTimewebClient(llm.TimewebConfig{ ProviderID: "timeweb", BaseURL: cfg.TimewebAPIBaseURL, AgentAccessID: cfg.TimewebAgentAccessID, APIKey: cfg.TimewebAPIKey, ModelKey: cfg.DefaultLLMModel, ProxySource: cfg.TimewebProxySource, }) if err == nil { return client } } if cfg.AnthropicAPIKey != "" && !isJWT(cfg.AnthropicAPIKey) { client, err := llm.NewAnthropicClient(llm.ProviderConfig{ ProviderID: "anthropic", APIKey: cfg.AnthropicAPIKey, ModelKey: "claude-3-5-sonnet-20241022", }) if err == nil { return client } } if cfg.OpenAIAPIKey != "" && !isJWT(cfg.OpenAIAPIKey) { client, err := llm.NewOpenAIClient(llm.ProviderConfig{ ProviderID: "openai", APIKey: cfg.OpenAIAPIKey, ModelKey: "gpt-4o-mini", }) if err == nil { return client } } return nil } func getEnvInt(key string, defaultValue int) int { if val := os.Getenv(key); val != "" { var result int if _, err := fmt.Sscanf(val, "%d", &result); err == nil { return result } } return defaultValue } func isJWT(s string) bool { return len(s) > 10 && s[:3] == "eyJ" }