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
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/gooseek/backend/pkg/config"
|
||||
"github.com/gooseek/backend/pkg/middleware"
|
||||
"github.com/gooseek/backend/pkg/ndjson"
|
||||
"github.com/gooseek/backend/pkg/storage"
|
||||
)
|
||||
|
||||
type SearchRequest struct {
|
||||
@@ -55,6 +56,24 @@ func main() {
|
||||
|
||||
searchClient := search.NewSearXNGClient(cfg)
|
||||
|
||||
var photoCache *agent.PhotoCacheService
|
||||
if cfg.MinioEndpoint != "" {
|
||||
minioStorage, err := storage.NewMinioStorage(storage.MinioConfig{
|
||||
Endpoint: cfg.MinioEndpoint,
|
||||
AccessKey: cfg.MinioAccessKey,
|
||||
SecretKey: cfg.MinioSecretKey,
|
||||
Bucket: cfg.MinioBucket,
|
||||
UseSSL: cfg.MinioUseSSL,
|
||||
PublicURL: cfg.MinioPublicURL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: MinIO init failed (photo cache disabled): %v", err)
|
||||
} else {
|
||||
photoCache = agent.NewPhotoCacheService(minioStorage)
|
||||
log.Printf("Photo cache enabled: MinIO at %s, bucket=%s, publicURL=%s", cfg.MinioEndpoint, cfg.MinioBucket, cfg.MinioPublicURL)
|
||||
}
|
||||
}
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
StreamRequestBody: true,
|
||||
BodyLimit: 10 * 1024 * 1024,
|
||||
@@ -166,6 +185,7 @@ func main() {
|
||||
TravelSvcURL: cfg.TravelSvcURL,
|
||||
TravelPayoutsToken: cfg.TravelPayoutsToken,
|
||||
TravelPayoutsMarker: cfg.TravelPayoutsMarker,
|
||||
PhotoCache: photoCache,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ func main() {
|
||||
"discover": cfg.DiscoverSvcURL,
|
||||
"finance": cfg.FinanceHeatmapURL,
|
||||
"learning": cfg.LearningSvcURL,
|
||||
"sandbox": cfg.SandboxSvcURL,
|
||||
"travel": cfg.TravelSvcURL,
|
||||
"medicine": cfg.MedicineSvcURL,
|
||||
"admin": cfg.AdminSvcURL,
|
||||
}
|
||||
|
||||
@@ -140,8 +142,12 @@ func getTarget(path string) (base, rewrite string) {
|
||||
return svcURLs["finance"], path
|
||||
case strings.HasPrefix(path, "/api/v1/learning"):
|
||||
return svcURLs["learning"], path
|
||||
case strings.HasPrefix(path, "/api/v1/sandbox"):
|
||||
return svcURLs["sandbox"], path
|
||||
case strings.HasPrefix(path, "/api/v1/travel"):
|
||||
return svcURLs["travel"], path
|
||||
case strings.HasPrefix(path, "/api/v1/medicine"):
|
||||
return svcURLs["medicine"], path
|
||||
case strings.HasPrefix(path, "/api/v1/admin"):
|
||||
return svcURLs["admin"], path
|
||||
default:
|
||||
|
||||
@@ -2,119 +2,75 @@ 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"
|
||||
)
|
||||
|
||||
type LessonStore struct {
|
||||
lessons map[string]*learning.StepByStepLesson
|
||||
}
|
||||
|
||||
func NewLessonStore() *LessonStore {
|
||||
return &LessonStore{
|
||||
lessons: make(map[string]*learning.StepByStepLesson),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LessonStore) Save(lesson *learning.StepByStepLesson) {
|
||||
s.lessons[lesson.ID] = lesson
|
||||
}
|
||||
|
||||
func (s *LessonStore) Get(id string) *learning.StepByStepLesson {
|
||||
return s.lessons[id]
|
||||
}
|
||||
|
||||
func (s *LessonStore) List(limit, offset int) []*learning.StepByStepLesson {
|
||||
result := make([]*learning.StepByStepLesson, 0)
|
||||
i := 0
|
||||
for _, l := range s.lessons {
|
||||
if i >= offset && len(result) < limit {
|
||||
result = append(result, l)
|
||||
}
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *LessonStore) Delete(id string) bool {
|
||||
if _, ok := s.lessons[id]; ok {
|
||||
delete(s.lessons, id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load config:", err)
|
||||
}
|
||||
|
||||
var llmClient llm.Client
|
||||
var database *db.PostgresDB
|
||||
var repo *db.LearningRepository
|
||||
|
||||
// Priority 1: Timeweb Cloud AI (recommended for production)
|
||||
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 {
|
||||
log.Printf("Warning: Failed to create Timeweb client: %v", err)
|
||||
} else {
|
||||
llmClient = client
|
||||
log.Println("Using Timeweb Cloud AI as LLM provider")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Anthropic
|
||||
if llmClient == nil && 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 {
|
||||
log.Printf("Warning: Failed to create Anthropic client: %v", err)
|
||||
} else {
|
||||
llmClient = client
|
||||
log.Println("Using Anthropic as LLM provider")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: OpenAI (only if it's a real OpenAI key, not Timeweb JWT)
|
||||
if llmClient == nil && cfg.OpenAIAPIKey != "" && !isJWT(cfg.OpenAIAPIKey) {
|
||||
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
|
||||
ProviderID: "openai",
|
||||
APIKey: cfg.OpenAIAPIKey,
|
||||
ModelKey: "gpt-4o-mini",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create OpenAI client: %v", err)
|
||||
} else {
|
||||
llmClient = client
|
||||
log.Println("Using OpenAI as LLM provider")
|
||||
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. Please set TIMEWEB_AGENT_ACCESS_ID + TIMEWEB_API_KEY, or OPENAI_API_KEY, or ANTHROPIC_API_KEY")
|
||||
log.Fatal("No LLM provider configured")
|
||||
}
|
||||
|
||||
generator := learning.NewLearningGenerator(llmClient)
|
||||
store := NewLessonStore()
|
||||
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,
|
||||
@@ -129,259 +85,296 @@ func main() {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/lesson", func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
Query string `json:"query"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Mode string `json:"mode"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
Locale string `json:"locale"`
|
||||
IncludeCode bool `json:"includeCode"`
|
||||
IncludeQuiz bool `json:"includeQuiz"`
|
||||
}
|
||||
api := app.Group("/api/v1/learning", middleware.JWT(middleware.JWTConfig{
|
||||
Secret: cfg.JWTSecret,
|
||||
AuthSvcURL: cfg.AuthSvcURL,
|
||||
AllowGuest: true,
|
||||
}))
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
difficulty := learning.DifficultyBeginner
|
||||
switch req.Difficulty {
|
||||
case "intermediate":
|
||||
difficulty = learning.DifficultyIntermediate
|
||||
case "advanced":
|
||||
difficulty = learning.DifficultyAdvanced
|
||||
case "expert":
|
||||
difficulty = learning.DifficultyExpert
|
||||
}
|
||||
|
||||
mode := learning.ModeExplain
|
||||
switch req.Mode {
|
||||
case "guided":
|
||||
mode = learning.ModeGuided
|
||||
case "interactive":
|
||||
mode = learning.ModeInteractive
|
||||
case "practice":
|
||||
mode = learning.ModePractice
|
||||
case "quiz":
|
||||
mode = learning.ModeQuiz
|
||||
}
|
||||
|
||||
lesson, err := generator.GenerateLesson(ctx, learning.GenerateLessonOptions{
|
||||
Topic: req.Topic,
|
||||
Query: req.Query,
|
||||
Difficulty: difficulty,
|
||||
Mode: mode,
|
||||
MaxSteps: req.MaxSteps,
|
||||
Locale: req.Locale,
|
||||
IncludeCode: req.IncludeCode,
|
||||
IncludeQuiz: req.IncludeQuiz,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
store.Save(lesson)
|
||||
|
||||
return c.JSON(lesson)
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/explain", func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
difficulty := learning.DifficultyBeginner
|
||||
switch req.Difficulty {
|
||||
case "intermediate":
|
||||
difficulty = learning.DifficultyIntermediate
|
||||
case "advanced":
|
||||
difficulty = learning.DifficultyAdvanced
|
||||
case "expert":
|
||||
difficulty = learning.DifficultyExpert
|
||||
}
|
||||
|
||||
step, err := generator.GenerateExplanation(ctx, req.Topic, difficulty, req.Locale)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(step)
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/quiz", func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
NumQuestions int `json:"numQuestions"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if req.NumQuestions == 0 {
|
||||
req.NumQuestions = 5
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
difficulty := learning.DifficultyBeginner
|
||||
switch req.Difficulty {
|
||||
case "intermediate":
|
||||
difficulty = learning.DifficultyIntermediate
|
||||
case "advanced":
|
||||
difficulty = learning.DifficultyAdvanced
|
||||
case "expert":
|
||||
difficulty = learning.DifficultyExpert
|
||||
}
|
||||
|
||||
questions, err := generator.GenerateQuiz(ctx, req.Topic, req.NumQuestions, difficulty, req.Locale)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"questions": questions})
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/practice", func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
Language string `json:"language"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
difficulty := learning.DifficultyBeginner
|
||||
switch req.Difficulty {
|
||||
case "intermediate":
|
||||
difficulty = learning.DifficultyIntermediate
|
||||
case "advanced":
|
||||
difficulty = learning.DifficultyAdvanced
|
||||
case "expert":
|
||||
difficulty = learning.DifficultyExpert
|
||||
}
|
||||
|
||||
exercise, err := generator.GeneratePracticeExercise(ctx, req.Topic, req.Language, difficulty, req.Locale)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(exercise)
|
||||
})
|
||||
|
||||
app.Get("/api/v1/learning/lessons", func(c *fiber.Ctx) error {
|
||||
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)
|
||||
|
||||
lessons := store.List(limit, offset)
|
||||
|
||||
summaries := make([]map[string]interface{}, 0)
|
||||
for _, l := range lessons {
|
||||
summaries = append(summaries, map[string]interface{}{
|
||||
"id": l.ID,
|
||||
"title": l.Title,
|
||||
"topic": l.Topic,
|
||||
"difficulty": l.Difficulty,
|
||||
"mode": l.Mode,
|
||||
"stepsCount": len(l.Steps),
|
||||
"estimatedTime": l.EstimatedTime,
|
||||
"progress": l.Progress,
|
||||
"createdAt": l.CreatedAt,
|
||||
})
|
||||
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{"lessons": summaries, "count": len(summaries)})
|
||||
return c.JSON(fiber.Map{"courses": courses, "total": total})
|
||||
})
|
||||
|
||||
app.Get("/api/v1/learning/lessons/:id", func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
lesson := store.Get(id)
|
||||
if lesson == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
|
||||
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"})
|
||||
}
|
||||
return c.JSON(lesson)
|
||||
if course == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Course not found"})
|
||||
}
|
||||
return c.JSON(course)
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/lessons/:id/complete-step", func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
lesson := store.Get(id)
|
||||
if lesson == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
|
||||
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 {
|
||||
StepIndex int `json:"stepIndex"`
|
||||
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"})
|
||||
}
|
||||
|
||||
lesson.CompleteStep(req.StepIndex)
|
||||
if req.Profile == nil {
|
||||
req.Profile = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"progress": lesson.Progress,
|
||||
})
|
||||
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})
|
||||
})
|
||||
|
||||
app.Post("/api/v1/learning/lessons/:id/submit-answer", func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
lesson := store.Get(id)
|
||||
if lesson == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
|
||||
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 {
|
||||
StepIndex int `json:"stepIndex"`
|
||||
SelectedOptions []string `json:"selectedOptions"`
|
||||
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"})
|
||||
}
|
||||
|
||||
correct, explanation := lesson.SubmitQuizAnswer(req.StepIndex, req.SelectedOptions)
|
||||
|
||||
if correct {
|
||||
lesson.CompleteStep(req.StepIndex)
|
||||
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"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"correct": correct,
|
||||
"explanation": explanation,
|
||||
"progress": lesson.Progress,
|
||||
})
|
||||
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})
|
||||
})
|
||||
|
||||
app.Delete("/api/v1/learning/lessons/:id", func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if store.Delete(id) {
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
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"})
|
||||
}
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
|
||||
|
||||
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)
|
||||
@@ -389,6 +382,43 @@ func main() {
|
||||
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
|
||||
|
||||
120
backend/cmd/medicine-svc/main.go
Normal file
120
backend/cmd/medicine-svc/main.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"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/llm"
|
||||
"github.com/gooseek/backend/internal/medicine"
|
||||
"github.com/gooseek/backend/pkg/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
llmClient := createLLMClient()
|
||||
if llmClient == nil {
|
||||
log.Println("medicine-svc: no LLM configured, fallback mode enabled")
|
||||
}
|
||||
|
||||
svc := medicine.NewService(medicine.ServiceConfig{
|
||||
LLM: llmClient,
|
||||
SearXNGURL: getEnv("SEARXNG_URL", "http://searxng:8080"),
|
||||
Timeout: 20 * time.Second,
|
||||
})
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 60 * 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", "service": "medicine-svc"})
|
||||
})
|
||||
|
||||
jwtOptional := middleware.JWTConfig{
|
||||
Secret: os.Getenv("JWT_SECRET"),
|
||||
AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"),
|
||||
AllowGuest: true,
|
||||
}
|
||||
|
||||
api := app.Group("/api/v1/medicine")
|
||||
api.Post("/consult", middleware.JWT(jwtOptional), func(c *fiber.Ctx) error {
|
||||
var req medicine.ConsultRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if req.Symptoms == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "symptoms is required"})
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "application/x-ndjson")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Transfer-Encoding", "chunked")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if err := svc.StreamConsult(ctx, req, w); err != nil {
|
||||
log.Printf("medicine consult error: %v", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
port := getEnvInt("PORT", 3037)
|
||||
log.Printf("medicine-svc listening on :%d", port)
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
|
||||
func createLLMClient() llm.Client {
|
||||
cfg := llm.ProviderConfig{
|
||||
ProviderID: getEnv("LLM_PROVIDER", "timeweb"),
|
||||
ModelKey: getEnv("LLM_MODEL", "gpt-4o-mini"),
|
||||
BaseURL: os.Getenv("TIMEWEB_API_BASE_URL"),
|
||||
APIKey: os.Getenv("TIMEWEB_API_KEY"),
|
||||
AgentAccessID: os.Getenv("TIMEWEB_AGENT_ACCESS_ID"),
|
||||
}
|
||||
client, err := llm.NewClient(cfg)
|
||||
if err == nil {
|
||||
return client
|
||||
}
|
||||
if os.Getenv("OPENAI_API_KEY") != "" {
|
||||
openAIClient, openAIErr := llm.NewClient(llm.ProviderConfig{
|
||||
ProviderID: "openai",
|
||||
ModelKey: "gpt-4o-mini",
|
||||
APIKey: os.Getenv("OPENAI_API_KEY"),
|
||||
})
|
||||
if openAIErr == nil {
|
||||
return openAIClient
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
var out int
|
||||
if _, err := fmt.Sscanf(v, "%d", &out); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
540
backend/cmd/sandbox-svc/main.go
Normal file
540
backend/cmd/sandbox-svc/main.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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/pkg/config"
|
||||
"github.com/gooseek/backend/pkg/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
openSandboxURL string
|
||||
repo *db.LearningRepository
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load config:", err)
|
||||
}
|
||||
|
||||
openSandboxURL = getEnv("OPENSANDBOX_URL", "http://opensandbox-server:8080")
|
||||
|
||||
var database *db.PostgresDB
|
||||
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 sandbox-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")
|
||||
} else {
|
||||
log.Fatal("DATABASE_URL required for sandbox-svc")
|
||||
}
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
BodyLimit: 50 * 1024 * 1024,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
})
|
||||
|
||||
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/sandbox", middleware.JWT(middleware.JWTConfig{
|
||||
Secret: cfg.JWTSecret,
|
||||
AuthSvcURL: cfg.AuthSvcURL,
|
||||
AllowGuest: false,
|
||||
}))
|
||||
|
||||
api.Post("/sessions", handleCreateSession)
|
||||
api.Get("/sessions/:id", handleGetSession)
|
||||
api.Get("/sessions/:id/files", handleListFiles)
|
||||
api.Get("/sessions/:id/file", handleReadFile)
|
||||
api.Put("/sessions/:id/file", handleWriteFile)
|
||||
api.Post("/sessions/:id/commands/run", handleRunCommand)
|
||||
api.Post("/sessions/:id/verify", handleVerify)
|
||||
|
||||
port := getEnvInt("SANDBOX_SVC_PORT", 3036)
|
||||
log.Printf("sandbox-svc listening on :%d", port)
|
||||
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
||||
}
|
||||
|
||||
func handleCreateSession(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "Authentication required"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
image := req.Image
|
||||
if image == "" {
|
||||
image = "opensandbox/code-interpreter:v1.0.1"
|
||||
}
|
||||
|
||||
sandboxResp, err := createOpenSandbox(image)
|
||||
if err != nil {
|
||||
log.Printf("OpenSandbox create error: %v", err)
|
||||
return c.Status(503).JSON(fiber.Map{"error": "Sandbox creation failed: " + err.Error()})
|
||||
}
|
||||
|
||||
session := &db.SandboxSession{
|
||||
UserID: userID,
|
||||
OpenSandboxID: sandboxResp.ID,
|
||||
Status: "ready",
|
||||
Metadata: json.RawMessage(fmt.Sprintf(`{"image":"%s"}`, image)),
|
||||
}
|
||||
if req.TaskID != "" {
|
||||
session.TaskID = &req.TaskID
|
||||
}
|
||||
|
||||
if err := repo.CreateSandboxSession(c.Context(), session); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to save session"})
|
||||
}
|
||||
|
||||
logEvent(c.Context(), session.ID, "session_created", map[string]interface{}{"image": image})
|
||||
|
||||
return c.Status(201).JSON(session)
|
||||
}
|
||||
|
||||
func handleGetSession(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
return c.JSON(session)
|
||||
}
|
||||
|
||||
func handleListFiles(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
path := c.Query("path", "/home/user")
|
||||
result, err := sandboxFilesRequest(session.OpenSandboxID, path)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to list files"})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func handleReadFile(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "path query required"})
|
||||
}
|
||||
|
||||
content, err := sandboxReadFile(session.OpenSandboxID, path)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to read file"})
|
||||
}
|
||||
|
||||
logEvent(c.Context(), session.ID, "file_read", map[string]interface{}{"path": path})
|
||||
|
||||
return c.JSON(fiber.Map{"path": path, "content": content})
|
||||
}
|
||||
|
||||
func handleWriteFile(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if err := sandboxWriteFile(session.OpenSandboxID, req.Path, req.Content); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to write file"})
|
||||
}
|
||||
|
||||
logEvent(c.Context(), session.ID, "file_write", map[string]interface{}{
|
||||
"path": req.Path,
|
||||
"size": len(req.Content),
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
|
||||
func handleRunCommand(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Cwd string `json:"cwd"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, req.Cwd)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Failed to run command"})
|
||||
}
|
||||
|
||||
logEvent(c.Context(), session.ID, "command_run", map[string]interface{}{
|
||||
"command": req.Command,
|
||||
"exit_code": result["exit_code"],
|
||||
})
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func handleVerify(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserID(c)
|
||||
session, err := repo.GetSandboxSession(c.Context(), c.Params("id"))
|
||||
if err != nil || session == nil || session.UserID != userID {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if req.Command == "" {
|
||||
if session.TaskID != nil {
|
||||
task, _ := repo.GetTask(c.Context(), *session.TaskID)
|
||||
if task != nil && task.VerificationCmd != "" {
|
||||
req.Command = task.VerificationCmd
|
||||
}
|
||||
}
|
||||
if req.Command == "" {
|
||||
req.Command = "echo 'No verification command configured'"
|
||||
}
|
||||
}
|
||||
|
||||
result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, "")
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Verification failed"})
|
||||
}
|
||||
|
||||
logEvent(c.Context(), session.ID, "verify", map[string]interface{}{
|
||||
"command": req.Command,
|
||||
"exit_code": result["exit_code"],
|
||||
"stdout": result["stdout"],
|
||||
})
|
||||
|
||||
passed := false
|
||||
if exitCode, ok := result["exit_code"].(float64); ok && exitCode == 0 {
|
||||
passed = true
|
||||
}
|
||||
|
||||
if session.TaskID != nil {
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
submission := &db.LearningSubmission{
|
||||
TaskID: *session.TaskID,
|
||||
SandboxSessionID: &session.ID,
|
||||
Result: resultJSON,
|
||||
Score: 0,
|
||||
MaxScore: 100,
|
||||
}
|
||||
if passed {
|
||||
submission.Score = 100
|
||||
}
|
||||
repo.CreateSubmission(c.Context(), submission)
|
||||
repo.UpdateTaskStatus(c.Context(), *session.TaskID, "verified")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"passed": passed,
|
||||
"result": result,
|
||||
"sessionId": session.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// --- OpenSandbox HTTP client ---
|
||||
|
||||
type sandboxCreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
SandboxID string `json:"sandbox_id"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func createOpenSandbox(image string) (*sandboxCreateResponse, error) {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"image": image,
|
||||
"entrypoint": []string{"/opt/opensandbox/code-interpreter.sh"},
|
||||
"timeout": "30m",
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", openSandboxURL+"/api/v1/sandboxes", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opensandbox unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("opensandbox error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result sandboxCreateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.ID == "" {
|
||||
if result.SandboxID != "" {
|
||||
result.ID = result.SandboxID
|
||||
} else if result.Data.ID != "" {
|
||||
result.ID = result.Data.ID
|
||||
}
|
||||
}
|
||||
if result.ID == "" {
|
||||
return nil, fmt.Errorf("opensandbox response missing sandbox id")
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func sandboxFilesRequest(sandboxID, path string) (interface{}, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("files request failed: status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sandboxReadFile(sandboxID, path string) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/read?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("read file failed: status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var structured map[string]interface{}
|
||||
if err := json.Unmarshal(body, &structured); err == nil {
|
||||
if content, ok := structured["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
if data, ok := structured["data"].(map[string]interface{}); ok {
|
||||
if content, ok := data["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func sandboxWriteFile(sandboxID, path, content string) error {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"entries": []map[string]interface{}{
|
||||
{"path": path, "data": content, "mode": 644},
|
||||
},
|
||||
})
|
||||
url := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/write", openSandboxURL, sandboxID)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("write file failed: status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sandboxRunCommand(sandboxID, command, cwd string) (map[string]interface{}, error) {
|
||||
if cwd == "" {
|
||||
cwd = "/home/user"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"cmd": command,
|
||||
"cwd": cwd,
|
||||
})
|
||||
url := fmt.Sprintf("%s/api/v1/sandboxes/%s/commands/run", openSandboxURL, sandboxID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("run command failed: status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeCommandResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeCommandResult(result map[string]interface{}) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := result["exit_code"]; !ok {
|
||||
if exitCode, exists := result["exitCode"]; exists {
|
||||
result["exit_code"] = exitCode
|
||||
}
|
||||
}
|
||||
if _, ok := result["stdout"]; !ok {
|
||||
if output, exists := result["output"]; exists {
|
||||
if s, ok := output.(string); ok {
|
||||
result["stdout"] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := result["stderr"]; !ok {
|
||||
result["stderr"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func logEvent(ctx context.Context, sessionID, eventType string, data map[string]interface{}) {
|
||||
if repo == nil {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(data)
|
||||
repo.CreateSandboxEvent(ctx, sessionID, eventType, payload)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ func main() {
|
||||
api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc))
|
||||
api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc))
|
||||
api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc))
|
||||
api.Post("/validate-itinerary", middleware.JWT(jwtOptional), handleValidateItinerary(svc))
|
||||
|
||||
port := getEnvInt("PORT", 3035)
|
||||
log.Printf("travel-svc listening on :%d", port)
|
||||
@@ -522,6 +523,28 @@ func handleSearchPlaces(svc *travel.Service) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func handleValidateItinerary(svc *travel.Service) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req travel.ValidateItineraryRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if len(req.Days) == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "days required"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := svc.ValidateItinerary(ctx, req)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
|
||||
Reference in New Issue
Block a user