- 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
435 lines
13 KiB
Go
435 lines
13 KiB
Go
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"
|
|
}
|