feat: Go backend, enhanced search, new widgets, Docker deploy

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
This commit is contained in:
home
2026-02-27 04:15:32 +03:00
parent 328d968f3f
commit 06fe57c765
285 changed files with 53132 additions and 1871 deletions

View File

@@ -0,0 +1,424 @@
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/llm"
"github.com/gooseek/backend/internal/podcast"
"github.com/gooseek/backend/pkg/config"
)
type PodcastStore struct {
podcasts map[string]*podcast.Podcast
}
func NewPodcastStore() *PodcastStore {
return &PodcastStore{
podcasts: make(map[string]*podcast.Podcast),
}
}
func (s *PodcastStore) Save(p *podcast.Podcast) {
s.podcasts[p.ID] = p
}
func (s *PodcastStore) Get(id string) *podcast.Podcast {
return s.podcasts[id]
}
func (s *PodcastStore) List(limit, offset int) []*podcast.Podcast {
result := make([]*podcast.Podcast, 0)
i := 0
for _, p := range s.podcasts {
if i >= offset && len(result) < limit {
result = append(result, p)
}
i++
}
return result
}
func (s *PodcastStore) GetLatest() *podcast.Podcast {
var latest *podcast.Podcast
for _, p := range s.podcasts {
if latest == nil || p.GeneratedAt.After(latest.GeneratedAt) {
latest = p
}
}
return latest
}
func (s *PodcastStore) GetByDate(date time.Time) *podcast.Podcast {
dateStr := date.Format("2006-01-02")
for _, p := range s.podcasts {
if p.Date.Format("2006-01-02") == dateStr {
return p
}
}
return nil
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
var llmClient llm.Client
if cfg.OpenAIAPIKey != "" {
client, err := llm.NewOpenAIClient(llm.ProviderConfig{
ProviderID: "openai",
APIKey: cfg.OpenAIAPIKey,
ModelKey: "gpt-4o-mini",
})
if err != nil {
log.Fatal("Failed to create OpenAI client:", err)
}
llmClient = client
} else if cfg.AnthropicAPIKey != "" {
client, err := llm.NewAnthropicClient(llm.ProviderConfig{
ProviderID: "anthropic",
APIKey: cfg.AnthropicAPIKey,
ModelKey: "claude-3-5-sonnet-20241022",
})
if err != nil {
log.Fatal("Failed to create Anthropic client:", err)
}
llmClient = client
}
var ttsClient podcast.TTSClient
elevenLabsKey := os.Getenv("ELEVENLABS_API_KEY")
if elevenLabsKey != "" {
ttsClient = podcast.NewElevenLabsTTS(elevenLabsKey)
} else {
ttsClient = &podcast.DummyTTS{}
}
generator := podcast.NewPodcastGenerator(llmClient, ttsClient, podcast.GeneratorConfig{
DefaultDuration: 300,
MaxDuration: 1800,
OutputDir: "/data/podcasts",
})
store := NewPodcastStore()
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/podcast/generate", func(c *fiber.Ctx) error {
var req struct {
Type string `json:"type"`
Topics []string `json:"topics"`
NewsItems []podcast.NewsItem `json:"newsItems"`
Duration int `json:"duration"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
podcastType := podcast.PodcastDaily
switch req.Type {
case "weekly":
podcastType = podcast.PodcastWeekly
case "topic_deep":
podcastType = podcast.PodcastTopicDeep
case "breaking":
podcastType = podcast.PodcastBreaking
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcastType,
Topics: req.Topics,
NewsItems: req.NewsItems,
Duration: req.Duration,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-daily", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
Locale string `json:"locale"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
}
if req.Locale == "" {
req.Locale = "ru"
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
p, err := generator.GenerateDailyPodcast(ctx, podcast.GenerateOptions{
Type: podcast.PodcastDaily,
NewsItems: req.NewsItems,
Date: time.Now(),
Duration: 300,
Locale: req.Locale,
IncludeIntro: true,
IncludeOutro: true,
})
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-weekly", func(c *fiber.Ctx) error {
var req struct {
NewsItems []podcast.NewsItem `json:"newsItems"`
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(), 180*time.Second)
defer cancel()
p, err := generator.GenerateWeeklySummary(ctx, req.NewsItems, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/generate-topic", func(c *fiber.Ctx) error {
var req struct {
Topic string `json:"topic"`
Articles []podcast.NewsItem `json:"articles"`
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(), 180*time.Second)
defer cancel()
p, err := generator.GenerateTopicDeepDive(ctx, req.Topic, req.Articles, req.Locale)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
store.Save(p)
return c.JSON(p)
})
app.Post("/api/v1/podcast/:id/audio", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
audioData, err := generator.GenerateAudio(ctx, p)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "audio/mpeg")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.mp3\"", p.ID))
return c.Send(audioData)
})
app.Get("/api/v1/podcasts", func(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 20)
offset := c.QueryInt("offset", 0)
podcasts := store.List(limit, offset)
summaries := make([]map[string]interface{}, 0)
for _, p := range podcasts {
summaries = append(summaries, map[string]interface{}{
"id": p.ID,
"title": p.Title,
"description": p.Description,
"type": p.Type,
"date": p.Date,
"duration": p.Duration,
"status": p.Status,
"topics": p.Topics,
"audioUrl": p.AudioURL,
"generatedAt": p.GeneratedAt,
})
}
return c.JSON(fiber.Map{"podcasts": summaries, "count": len(summaries)})
})
app.Get("/api/v1/podcasts/latest", func(c *fiber.Ctx) error {
p := store.GetLatest()
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcasts found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/today", func(c *fiber.Ctx) error {
p := store.GetByDate(time.Now())
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "No podcast for today"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(p)
})
app.Get("/api/v1/podcasts/:id/transcript", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"id": p.ID,
"title": p.Title,
"transcript": p.Transcript,
"segments": p.Segments,
})
})
app.Get("/api/v1/podcasts/:id/segments", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
return c.JSON(fiber.Map{
"segments": p.Segments,
})
})
app.Post("/api/v1/podcast/:id/publish", func(c *fiber.Ctx) error {
id := c.Params("id")
p := store.Get(id)
if p == nil {
return c.Status(404).JSON(fiber.Map{"error": "Podcast not found"})
}
if p.Status != podcast.StatusReady {
return c.Status(400).JSON(fiber.Map{"error": "Podcast audio not ready"})
}
now := time.Now()
p.Status = podcast.StatusPublished
p.PublishedAt = &now
return c.JSON(fiber.Map{"success": true, "publishedAt": now})
})
app.Get("/api/v1/podcast/rss", func(c *fiber.Ctx) error {
podcasts := store.List(50, 0)
rss := generateRSSFeed(podcasts, c.BaseURL())
c.Set("Content-Type", "application/rss+xml")
return c.SendString(rss)
})
port := getEnvInt("PODCAST_SVC_PORT", 3032)
log.Printf("podcast-svc listening on :%d", port)
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
}
func generateRSSFeed(podcasts []*podcast.Podcast, baseURL string) string {
items := ""
for _, p := range podcasts {
if p.Status == podcast.StatusPublished && p.AudioURL != "" {
pubDate := ""
if p.PublishedAt != nil {
pubDate = p.PublishedAt.Format(time.RFC1123Z)
}
items += fmt.Sprintf(`
<item>
<title>%s</title>
<description><![CDATA[%s]]></description>
<pubDate>%s</pubDate>
<enclosure url="%s" type="audio/mpeg" length="0"/>
<guid>%s</guid>
<itunes:duration>%d</itunes:duration>
</item>`, p.Title, p.Description, pubDate, p.AudioURL, p.ID, p.Duration)
}
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>GooSeek Daily</title>
<link>%s</link>
<description>Ежедневный подкаст с главными новостями от GooSeek</description>
<language>ru</language>
<itunes:author>GooSeek</itunes:author>
<itunes:category text="News"/>
<atom:link href="%s/api/v1/podcast/rss" rel="self" type="application/rss+xml"/>
%s
</channel>
</rss>`, baseURL, baseURL, items)
}
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
}