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
554 lines
14 KiB
Go
554 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/labs"
|
|
"github.com/gooseek/backend/internal/llm"
|
|
"github.com/gooseek/backend/pkg/config"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ReportStore struct {
|
|
reports map[string]*labs.Report
|
|
}
|
|
|
|
func NewReportStore() *ReportStore {
|
|
return &ReportStore{
|
|
reports: make(map[string]*labs.Report),
|
|
}
|
|
}
|
|
|
|
func (s *ReportStore) Save(report *labs.Report) {
|
|
s.reports[report.ID] = report
|
|
}
|
|
|
|
func (s *ReportStore) Get(id string) *labs.Report {
|
|
return s.reports[id]
|
|
}
|
|
|
|
func (s *ReportStore) List(limit, offset int) []*labs.Report {
|
|
result := make([]*labs.Report, 0)
|
|
i := 0
|
|
for _, r := range s.reports {
|
|
if i >= offset && len(result) < limit {
|
|
result = append(result, r)
|
|
}
|
|
i++
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *ReportStore) Delete(id string) bool {
|
|
if _, ok := s.reports[id]; ok {
|
|
delete(s.reports, 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
|
|
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
|
|
}
|
|
|
|
generator := labs.NewGenerator(llmClient)
|
|
store := NewReportStore()
|
|
|
|
app := fiber.New(fiber.Config{
|
|
BodyLimit: 100 * 1024 * 1024,
|
|
ReadTimeout: 60 * time.Second,
|
|
WriteTimeout: 60 * 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/labs/generate", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Query string `json:"query"`
|
|
Data interface{} `json:"data"`
|
|
Theme string `json:"theme,omitempty"`
|
|
Locale string `json:"locale,omitempty"`
|
|
}
|
|
|
|
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()
|
|
|
|
report, err := generator.GenerateReport(ctx, labs.GenerateOptions{
|
|
Query: req.Query,
|
|
Data: req.Data,
|
|
Theme: req.Theme,
|
|
Locale: req.Locale,
|
|
})
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
store.Save(report)
|
|
|
|
return c.JSON(report)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/visualize", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Data interface{} `json:"data"`
|
|
Config interface{} `json:"config,omitempty"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := createVisualizationFromRequest(generator, req.Type, req.Title, req.Data, req.Config)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/chart", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Labels []string `json:"labels"`
|
|
Datasets []labs.ChartDataset `json:"datasets"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
var viz labs.Visualization
|
|
switch req.Type {
|
|
case "bar", "bar_chart":
|
|
if len(req.Datasets) > 0 {
|
|
viz = generator.CreateBarChart(req.Title, req.Labels, req.Datasets[0].Data)
|
|
}
|
|
case "line", "line_chart":
|
|
viz = generator.CreateLineChart(req.Title, req.Labels, req.Datasets)
|
|
case "pie", "pie_chart":
|
|
if len(req.Datasets) > 0 {
|
|
viz = generator.CreatePieChart(req.Title, req.Labels, req.Datasets[0].Data)
|
|
}
|
|
default:
|
|
viz = generator.CreateBarChart(req.Title, req.Labels, req.Datasets[0].Data)
|
|
}
|
|
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/table", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Columns []labs.TableColumn `json:"columns"`
|
|
Rows []labs.TableRow `json:"rows"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateTable(req.Title, req.Columns, req.Rows)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/stat-cards", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Cards []labs.StatCard `json:"cards"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateStatCards(req.Title, req.Cards)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/kpi", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Value interface{} `json:"value"`
|
|
Change float64 `json:"change"`
|
|
Unit string `json:"unit"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateKPI(req.Title, req.Value, req.Change, req.Unit)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/heatmap", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
XLabels []string `json:"xLabels"`
|
|
YLabels []string `json:"yLabels"`
|
|
Values [][]float64 `json:"values"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateHeatmap(req.Title, req.XLabels, req.YLabels, req.Values)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/code", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Code string `json:"code"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateCodeBlock(req.Title, req.Code, req.Language)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/markdown", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateMarkdown(req.Title, req.Content)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/tabs", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Tabs []labs.TabItem `json:"tabs"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateTabs(req.Title, req.Tabs)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Post("/api/v1/labs/accordion", func(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Items []labs.AccordionItem `json:"items"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
viz := generator.CreateAccordion(req.Title, req.Items)
|
|
return c.JSON(viz)
|
|
})
|
|
|
|
app.Get("/api/v1/labs/reports", func(c *fiber.Ctx) error {
|
|
limit := c.QueryInt("limit", 20)
|
|
offset := c.QueryInt("offset", 0)
|
|
|
|
reports := store.List(limit, offset)
|
|
return c.JSON(fiber.Map{"reports": reports, "count": len(reports)})
|
|
})
|
|
|
|
app.Get("/api/v1/labs/reports/:id", func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
report := store.Get(id)
|
|
if report == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "Report not found"})
|
|
}
|
|
return c.JSON(report)
|
|
})
|
|
|
|
app.Delete("/api/v1/labs/reports/: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": "Report not found"})
|
|
})
|
|
|
|
app.Post("/api/v1/labs/reports/:id/export", func(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
format := c.Query("format", "html")
|
|
|
|
report := store.Get(id)
|
|
if report == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "Report not found"})
|
|
}
|
|
|
|
switch format {
|
|
case "html":
|
|
html := exportToHTML(report)
|
|
c.Set("Content-Type", "text/html")
|
|
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.html\"", report.ID))
|
|
return c.SendString(html)
|
|
case "json":
|
|
c.Set("Content-Type", "application/json")
|
|
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.json\"", report.ID))
|
|
return c.JSON(report)
|
|
default:
|
|
return c.Status(400).JSON(fiber.Map{"error": "Unsupported format"})
|
|
}
|
|
})
|
|
|
|
port := getEnvInt("LABS_SVC_PORT", 3031)
|
|
log.Printf("labs-svc listening on :%d", port)
|
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
|
}
|
|
|
|
func createVisualizationFromRequest(g *labs.Generator, vizType, title string, data, config interface{}) labs.Visualization {
|
|
switch vizType {
|
|
case "bar_chart":
|
|
return parseChartRequest(g, labs.VizBarChart, title, data)
|
|
case "line_chart":
|
|
return parseChartRequest(g, labs.VizLineChart, title, data)
|
|
case "pie_chart":
|
|
return parseChartRequest(g, labs.VizPieChart, title, data)
|
|
case "table":
|
|
return parseTableRequest(g, title, data)
|
|
case "stat_cards":
|
|
return parseStatCardsRequest(g, title, data)
|
|
case "kpi":
|
|
return parseKPIRequest(g, title, data)
|
|
case "markdown":
|
|
content := ""
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
content, _ = dataMap["content"].(string)
|
|
}
|
|
return g.CreateMarkdown(title, content)
|
|
case "code_block":
|
|
code, lang := "", ""
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
code, _ = dataMap["code"].(string)
|
|
lang, _ = dataMap["language"].(string)
|
|
}
|
|
return g.CreateCodeBlock(title, code, lang)
|
|
default:
|
|
return g.CreateMarkdown(title, fmt.Sprintf("%v", data))
|
|
}
|
|
}
|
|
|
|
func parseChartRequest(g *labs.Generator, vizType labs.VisualizationType, title string, data interface{}) labs.Visualization {
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
return g.CreateMarkdown(title, "Invalid chart data")
|
|
}
|
|
|
|
labels := make([]string, 0)
|
|
if labelsRaw, ok := dataMap["labels"].([]interface{}); ok {
|
|
for _, l := range labelsRaw {
|
|
labels = append(labels, fmt.Sprintf("%v", l))
|
|
}
|
|
}
|
|
|
|
values := make([]float64, 0)
|
|
if valuesRaw, ok := dataMap["values"].([]interface{}); ok {
|
|
for _, v := range valuesRaw {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
values = append(values, val)
|
|
case int:
|
|
values = append(values, float64(val))
|
|
}
|
|
}
|
|
}
|
|
|
|
switch vizType {
|
|
case labs.VizBarChart:
|
|
return g.CreateBarChart(title, labels, values)
|
|
case labs.VizPieChart:
|
|
return g.CreatePieChart(title, labels, values)
|
|
default:
|
|
return g.CreateLineChart(title, labels, []labs.ChartDataset{{Label: title, Data: values}})
|
|
}
|
|
}
|
|
|
|
func parseTableRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
return g.CreateMarkdown(title, "Invalid table data")
|
|
}
|
|
|
|
columns := make([]labs.TableColumn, 0)
|
|
if colsRaw, ok := dataMap["columns"].([]interface{}); ok {
|
|
for _, c := range colsRaw {
|
|
if colMap, ok := c.(map[string]interface{}); ok {
|
|
col := labs.TableColumn{}
|
|
if v, ok := colMap["key"].(string); ok {
|
|
col.Key = v
|
|
}
|
|
if v, ok := colMap["label"].(string); ok {
|
|
col.Label = v
|
|
}
|
|
columns = append(columns, col)
|
|
}
|
|
}
|
|
}
|
|
|
|
rows := make([]labs.TableRow, 0)
|
|
if rowsRaw, ok := dataMap["rows"].([]interface{}); ok {
|
|
for _, r := range rowsRaw {
|
|
if rowMap, ok := r.(map[string]interface{}); ok {
|
|
rows = append(rows, labs.TableRow(rowMap))
|
|
}
|
|
}
|
|
}
|
|
|
|
return g.CreateTable(title, columns, rows)
|
|
}
|
|
|
|
func parseStatCardsRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
return g.CreateMarkdown(title, "Invalid stat cards data")
|
|
}
|
|
|
|
cards := make([]labs.StatCard, 0)
|
|
if cardsRaw, ok := dataMap["cards"].([]interface{}); ok {
|
|
for _, c := range cardsRaw {
|
|
if cardMap, ok := c.(map[string]interface{}); ok {
|
|
card := labs.StatCard{ID: uuid.New().String()}
|
|
if v, ok := cardMap["title"].(string); ok {
|
|
card.Title = v
|
|
}
|
|
if v, ok := cardMap["value"]; ok {
|
|
card.Value = v
|
|
}
|
|
if v, ok := cardMap["change"].(float64); ok {
|
|
card.Change = v
|
|
}
|
|
if v, ok := cardMap["color"].(string); ok {
|
|
card.Color = v
|
|
}
|
|
cards = append(cards, card)
|
|
}
|
|
}
|
|
}
|
|
|
|
return g.CreateStatCards(title, cards)
|
|
}
|
|
|
|
func parseKPIRequest(g *labs.Generator, title string, data interface{}) labs.Visualization {
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
return g.CreateKPI(title, data, 0, "")
|
|
}
|
|
|
|
value := dataMap["value"]
|
|
change := 0.0
|
|
if v, ok := dataMap["change"].(float64); ok {
|
|
change = v
|
|
}
|
|
unit := ""
|
|
if v, ok := dataMap["unit"].(string); ok {
|
|
unit = v
|
|
}
|
|
|
|
return g.CreateKPI(title, value, change, unit)
|
|
}
|
|
|
|
func exportToHTML(report *labs.Report) string {
|
|
sectionsHTML := ""
|
|
for _, section := range report.Sections {
|
|
vizHTML := ""
|
|
for _, viz := range section.Visualizations {
|
|
dataJSON, _ := json.Marshal(viz.Data)
|
|
vizHTML += fmt.Sprintf(`
|
|
<div class="visualization" data-type="%s">
|
|
<h3>%s</h3>
|
|
<div class="viz-container" data-config='%s'></div>
|
|
</div>
|
|
`, viz.Type, viz.Title, string(dataJSON))
|
|
}
|
|
sectionsHTML += fmt.Sprintf(`
|
|
<section>
|
|
<h2>%s</h2>
|
|
%s
|
|
</section>
|
|
`, section.Title, vizHTML)
|
|
}
|
|
|
|
return fmt.Sprintf(`<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>%s</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
h1 { color: #1f2937; }
|
|
h2 { color: #374151; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px; }
|
|
.visualization { margin: 20px 0; padding: 20px; background: #f9fafb; border-radius: 8px; }
|
|
.viz-container { min-height: 200px; }
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
</head>
|
|
<body>
|
|
<h1>%s</h1>
|
|
%s
|
|
</body>
|
|
</html>`, report.Title, report.Title, sectionsHTML)
|
|
}
|
|
|
|
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
|
|
}
|