Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
405 lines
10 KiB
Go
405 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"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/learning"
|
|
"github.com/gooseek/backend/internal/llm"
|
|
"github.com/gooseek/backend/pkg/config"
|
|
)
|
|
|
|
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
|
|
|
|
// 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 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")
|
|
}
|
|
|
|
generator := learning.NewLearningGenerator(llmClient)
|
|
store := NewLessonStore()
|
|
|
|
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"})
|
|
})
|
|
|
|
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"`
|
|
}
|
|
|
|
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 {
|
|
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,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"lessons": summaries, "count": len(summaries)})
|
|
})
|
|
|
|
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"})
|
|
}
|
|
return c.JSON(lesson)
|
|
})
|
|
|
|
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"})
|
|
}
|
|
|
|
var req struct {
|
|
StepIndex int `json:"stepIndex"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
lesson.CompleteStep(req.StepIndex)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"progress": lesson.Progress,
|
|
})
|
|
})
|
|
|
|
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"})
|
|
}
|
|
|
|
var req struct {
|
|
StepIndex int `json:"stepIndex"`
|
|
SelectedOptions []string `json:"selectedOptions"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"correct": correct,
|
|
"explanation": explanation,
|
|
"progress": lesson.Progress,
|
|
})
|
|
})
|
|
|
|
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})
|
|
}
|
|
return c.Status(404).JSON(fiber.Map{"error": "Lesson not found"})
|
|
})
|
|
|
|
port := getEnvInt("LEARNING_SVC_PORT", 3034)
|
|
log.Printf("learning-svc listening on :%d", port)
|
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
|
}
|
|
|
|
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"
|
|
}
|