Files
home ab48a0632b
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
feat: CI/CD pipeline + Learning/Medicine/Travel services
- 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
2026-03-02 20:25:44 +03:00

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