Initial commit: DeepRes Go backend — bot + worker + embed service

This commit is contained in:
Emil Shanaty
2026-05-28 14:48:51 +03:00
commit 34577871f1
21 changed files with 1287 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
// DeepRes — Telegram bot for deep research
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
// Telegram
TelegramToken string
// Database
DatabaseURL string
// NATS
NATSURL string
// SearXNG
SearXNGURL string
// LLM API (OpenRouter)
OpenRouterKey string
LLMModel string
// Embeddings (E5 via sentence-transformers HTTP server)
EmbedURL string
// Prices in Telegram Stars
FastResearchCost int
DeepResearchCost int
FreeFastPerDay int
}
func Load() *Config {
cfg := &Config{
TelegramToken: mustEnv("TELEGRAM_TOKEN"),
DatabaseURL: mustEnv("DATABASE_URL"),
NATSURL: getEnv("NATS_URL", "nats://localhost:4222"),
SearXNGURL: getEnv("SEARXNG_URL", "http://localhost:4000"),
OpenRouterKey: mustEnv("OPENROUTER_API_KEY"),
LLMModel: getEnv("LLM_MODEL", "deepseek/deepseek-v4-flash"),
EmbedURL: getEnv("EMBED_URL", "http://localhost:8081"),
FastResearchCost: getEnvInt("FAST_COST", 1),
DeepResearchCost: getEnvInt("DEEP_COST", 5),
FreeFastPerDay: getEnvInt("FREE_FAST_PER_DAY", 3),
}
return cfg
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultVal
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
panic(fmt.Sprintf("required environment variable %s is not set", key))
}
return v
}
+166
View File
@@ -0,0 +1,166 @@
package db
import (
"context"
"fmt"
"time"
"github.com/emil/deepres/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(databaseURL string) (*DB, error) {
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
return nil, fmt.Errorf("connect to db: %w", err)
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() { db.pool.Close() }
// ---- Users ----
func (db *DB) GetOrCreateUser(ctx context.Context, tgID int64, username, name string) (*models.User, error) {
u := &models.User{}
err := db.pool.QueryRow(ctx, `
INSERT INTO users (tg_id, username, name) VALUES ($1, $2, $3)
ON CONFLICT (tg_id) DO UPDATE SET username=$2, name=$3
RETURNING tg_id, username, name, stars_balance, created_at
`, tgID, username, name).Scan(&u.TelegramID, &u.Username, &u.Name, &u.StarsBalance, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("get or create user: %w", err)
}
return u, nil
}
func (db *DB) GetUser(ctx context.Context, tgID int64) (*models.User, error) {
u := &models.User{}
err := db.pool.QueryRow(ctx,
"SELECT tg_id, username, name, stars_balance, created_at FROM users WHERE tg_id=$1", tgID,
).Scan(&u.TelegramID, &u.Username, &u.Name, &u.StarsBalance, &u.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return u, nil
}
// ---- Research ----
func (db *DB) CreateResearch(ctx context.Context, r *models.Research) error {
r.ID = uuid.New()
r.Status = "pending"
r.CreatedAt = time.Now()
_, err := db.pool.Exec(ctx, `
INSERT INTO researches (id, tg_id, query, mode, status, stars_cost, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
`, r.ID, r.TelegramID, r.Query, r.Mode, r.Status, r.StarsCost, r.CreatedAt)
return err
}
func (db *DB) UpdateResearch(ctx context.Context, id uuid.UUID, status, summary string, reportPath string, sources []byte, errMsg *string) error {
now := time.Now()
_, err := db.pool.Exec(ctx, `
UPDATE researches SET status=$2, summary=$3, report_path=$4, sources=$5, error_message=$6, completed_at=$7
WHERE id=$1
`, id, status, summary, reportPath, sources, errMsg, now)
return err
}
func (db *DB) GetResearch(ctx context.Context, id uuid.UUID) (*models.Research, error) {
r := &models.Research{}
err := db.pool.QueryRow(ctx, "SELECT id,tg_id,query,mode,status,stars_cost,summary,report_path,sources,error_message,created_at,completed_at FROM researches WHERE id=$1", id).
Scan(&r.ID, &r.TelegramID, &r.Query, &r.Mode, &r.Status, &r.StarsCost, &r.Summary, &r.ReportPath, &r.SourcesJSON, &r.ErrorMessage, &r.CreatedAt, &r.CompletedAt)
if err != nil {
return nil, err
}
return r, nil
}
// ---- Transactions ----
// AddTransaction добавляет транзакцию и обновляет баланс атомарно
func (db *DB) AddTransaction(ctx context.Context, t *models.Transaction) error {
tx, err := db.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
t.ID = uuid.New()
t.CreatedAt = time.Now()
_, err = tx.Exec(ctx, `
INSERT INTO transactions (id, tg_id, amount, type, description, telegram_charge_id, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
`, t.ID, t.TelegramID, t.Amount, t.Type, t.Description, t.TelegramChargeID, t.CreatedAt)
if err != nil {
return fmt.Errorf("insert transaction: %w", err)
}
// Обновляем баланс
_, err = tx.Exec(ctx, "UPDATE users SET stars_balance = stars_balance + $1 WHERE tg_id=$2", t.Amount, t.TelegramID)
if err != nil {
return fmt.Errorf("update balance: %w", err)
}
return tx.Commit(ctx)
}
// DeductResearchCost списывает звёзды за ресерч атомарно
func (db *DB) DeductResearchCost(ctx context.Context, userID int64, researchID string, cost int, description string) error {
tx, err := db.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
// Проверяем баланс
var balance int
err = tx.QueryRow(ctx, "SELECT stars_balance FROM users WHERE tg_id=$1 FOR UPDATE", userID).Scan(&balance)
if err != nil {
return fmt.Errorf("get balance: %w", err)
}
if balance < cost {
return fmt.Errorf("insufficient balance: %d < %d", balance, cost)
}
// Создаём транзакцию
_, err = tx.Exec(ctx, `
INSERT INTO transactions (id, tg_id, amount, type, description, created_at)
VALUES (gen_random_uuid(), $1, $2, 'research', $3, now())
`, userID, -cost, description)
if err != nil {
return fmt.Errorf("insert transaction: %w", err)
}
// Списываем
_, err = tx.Exec(ctx, "UPDATE users SET stars_balance = stars_balance - $1 WHERE tg_id=$2", cost, userID)
if err != nil {
return fmt.Errorf("deduct balance: %w", err)
}
return tx.Commit(ctx)
}
func (db *DB) UpdateStarsBalance(ctx context.Context, tgID int64, delta int) error {
_, err := db.pool.Exec(ctx, "UPDATE users SET stars_balance = stars_balance + $1 WHERE tg_id=$2", delta, tgID)
return err
}
func (db *DB) GetTodayFastCount(ctx context.Context, tgID int64) (int, error) {
var count int
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM researches
WHERE tg_id=$1 AND mode='fast' AND created_at > CURRENT_DATE
`, tgID).Scan(&count)
return count, err
}
+61
View File
@@ -0,0 +1,61 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
TelegramID int64 `db:"tg_id"`
Username string `db:"username"`
Name string `db:"name"`
StarsBalance int `db:"stars_balance"`
CreatedAt time.Time `db:"created_at"`
}
type Research struct {
ID uuid.UUID `db:"id"`
TelegramID int64 `db:"tg_id"`
Query string `db:"query"`
Mode string `db:"mode"` // fast | deep
Status string `db:"status"` // pending | processing | done | failed
StarsCost int `db:"stars_cost"`
Summary *string `db:"summary"`
ReportPath *string `db:"report_path"`
SourcesJSON []byte `db:"sources"`
ErrorMessage *string `db:"error_message"`
CreatedAt time.Time `db:"created_at"`
CompletedAt *time.Time `db:"completed_at"`
}
type Transaction struct {
ID uuid.UUID `db:"id"`
TelegramID int64 `db:"tg_id"`
Amount int `db:"amount"`
Type string `db:"type"` // purchase | research
Description string `db:"description"`
TelegramChargeID *string `db:"telegram_charge_id"`
CreatedAt time.Time `db:"created_at"`
}
type ResearchRequest struct {
UserTelegramID int64 `json:"tg_id"`
Query string `json:"query"`
Mode string `json:"mode"`
ResearchID string `json:"research_id"`
}
type ResearchResult struct {
ResearchID string `json:"research_id"`
Status string `json:"status"`
Summary string `json:"summary"`
ReportPath string `json:"report_path"`
Sources []Source `json:"sources"`
Error string `json:"error,omitempty"`
}
type Source struct {
Title string `json:"title"`
URL string `json:"url"`
}
+63
View File
@@ -0,0 +1,63 @@
package queue
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/emil/deepres/internal/models"
"github.com/nats-io/nats.go"
)
type Queue struct {
conn *nats.Conn
}
func New(url string) (*Queue, error) {
conn, err := nats.Connect(url, nats.Timeout(5*time.Second))
if err != nil {
return nil, fmt.Errorf("connect to nats: %w", err)
}
return &Queue{conn: conn}, nil
}
func (q *Queue) Close() { q.conn.Close() }
func (q *Queue) PublishFast(ctx context.Context, req *models.ResearchRequest) error {
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal fast request: %w", err)
}
return q.conn.Publish("research.fast", data)
}
func (q *Queue) PublishDeep(ctx context.Context, req *models.ResearchRequest) error {
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal deep request: %w", err)
}
return q.conn.Publish("research.deep", data)
}
func (q *Queue) SubscribeFast(handler func(*models.ResearchRequest)) (*nats.Subscription, error) {
sub, err := q.conn.QueueSubscribe("research.fast", "workers", func(msg *nats.Msg) {
var req models.ResearchRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
return
}
handler(&req)
})
return sub, err
}
func (q *Queue) SubscribeDeep(handler func(*models.ResearchRequest)) (*nats.Subscription, error) {
sub, err := q.conn.QueueSubscribe("research.deep", "workers", func(msg *nats.Msg) {
var req models.ResearchRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
return
}
handler(&req)
})
return sub, err
}
+105
View File
@@ -0,0 +1,105 @@
package dedup
import (
"context"
"sort"
"github.com/emil/deepres/internal/research/embed"
"github.com/emil/deepres/internal/research/search"
)
// DedupResult содержит результаты после дедупликации
type DedupResult struct {
Items []DedupItem
RawTexts []string
}
type DedupItem struct {
Title string
URL string
Content string
ClusterID int
}
// Deduplicate принимает результаты поиска, эмбеддит, кластеризует и дедуплицирует
func Deduplicate(ctx context.Context, embedCli *embed.EmbedClient, results []search.SearXNGResult, threshold float32) (*DedupResult, error) {
if len(results) == 0 {
return &DedupResult{}, nil
}
// Подготавливаем тексты для эмбеддинга
texts := make([]string, len(results))
for i, r := range results {
content := r.Title + ". " + r.Content
if len([]rune(content)) > 512 {
content = string([]rune(content)[:512])
}
texts[i] = content
}
// Эмбеддинги
embeddings, err := embedCli.Embed(ctx, texts)
if err != nil {
return nil, err
}
// Простая дедупликация: если косинусная близость > threshold — это дубликат
n := len(results)
clusters := make([]int, n)
clusterID := 0
for i := range clusters {
clusters[i] = -1
}
for i := 0; i < n; i++ {
if clusters[i] != -1 {
continue
}
clusters[i] = clusterID
for j := i + 1; j < n; j++ {
if clusters[j] != -1 {
continue
}
sim := embed.CosineSimilarity(embeddings[i], embeddings[j])
if sim >= threshold {
clusters[j] = clusterID
}
}
clusterID++
}
// Собираем результат: по одному лучшему элементу из каждого кластера
unique := make(map[int]*DedupItem)
rawTexts := make([]string, 0)
// Сначала собираем все
for i, r := range results {
key := clusters[i]
if _, exists := unique[key]; !exists {
unique[key] = &DedupItem{
Title: r.Title,
URL: r.URL,
Content: r.Content,
}
}
}
// Сортируем по clusterID
keys := make([]int, 0, len(unique))
for k := range unique {
keys = append(keys, k)
}
sort.Ints(keys)
var items []DedupItem
for _, k := range keys {
item := unique[k]
items = append(items, *item)
rawTexts = append(rawTexts, item.Title+". "+item.Content)
}
return &DedupResult{
Items: items,
RawTexts: rawTexts,
}, nil
}
+105
View File
@@ -0,0 +1,105 @@
package embed
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"time"
)
type EmbedClient struct {
baseURL string
client *http.Client
model string
}
type EmbedRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type EmbedResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
func NewEmbedClient(baseURL string) *EmbedClient {
return &EmbedClient{
baseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
model: "intfloat/e5-small-v2",
}
}
func (c *EmbedClient) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return [][]float32{}, nil
}
if len(texts) > 100 {
return nil, fmt.Errorf("batch too large: %d > 100", len(texts))
}
body := EmbedRequest{
Model: c.model,
Input: texts,
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal embed request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/embeddings", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("embed request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("embed call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embed service returned %d: %s", resp.StatusCode, string(bodyBytes))
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read embed response: %w", err)
}
var er EmbedResponse
if err := json.Unmarshal(respBody, &er); err != nil {
return nil, fmt.Errorf("parse embed response: %w", err)
}
if len(er.Data) != len(texts) {
return nil, fmt.Errorf("embed mismatch: expected %d embeddings, got %d", len(texts), len(er.Data))
}
result := make([][]float32, len(er.Data))
for i, d := range er.Data {
result[i] = d.Embedding
}
return result, nil
}
// CosineSimilarity вычисляет косинусную близость между двумя векторами
func CosineSimilarity(a, b []float32) float32 {
var dot, normA, normB float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
if normA == 0 || normB == 0 {
return 0
}
return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB)))
}
+219
View File
@@ -0,0 +1,219 @@
package pipeline
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/emil/deepres/internal/models"
"github.com/emil/deepres/internal/research/dedup"
"github.com/emil/deepres/internal/research/embed"
"github.com/emil/deepres/internal/research/search"
"github.com/emil/deepres/internal/research/synthesize"
)
type Pipeline struct {
searchCli *search.SearXNGClient
embedCli *embed.EmbedClient
llmCli *synthesize.LLMClient
reportsDir string
}
func New(searchCli *search.SearXNGClient, embedCli *embed.EmbedClient, llmCli *synthesize.LLMClient, reportsDir string) *Pipeline {
// Создаём директорию для отчётов если нет
if reportsDir != "" {
_ = os.MkdirAll(reportsDir, 0755)
}
return &Pipeline{
searchCli: searchCli,
embedCli: embedCli,
llmCli: llmCli,
reportsDir: reportsDir,
}
}
func (p *Pipeline) RunFast(ctx context.Context, req *models.ResearchRequest) (*models.ResearchResult, error) {
log.Printf("[fast] starting research: %s", req.Query)
// 1. Поиск — 3-5 запросов с разными формулировками
variations := generateQueryVariations(req.Query)
results, err := p.searchCli.SearchWithVariations(ctx, req.Query, variations, 10)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("ничего не найдено по запросу")
}
log.Printf("[fast] found %d raw results", len(results))
// 2. Эмбеддинги + дедупликация
deduped, err := dedup.Deduplicate(ctx, p.embedCli, results, 0.85)
if err != nil {
return nil, fmt.Errorf("dedup: %w", err)
}
log.Printf("[fast] after dedup: %d unique sources", len(deduped.Items))
if len(deduped.RawTexts) == 0 {
return nil, fmt.Errorf("не удалось обработать источники")
}
// 3. LLM-синтез выжимки
prompt := synthesize.BuildFastResearchPrompt(req.Query, deduped.RawTexts)
summary, err := p.llmCli.Chat(ctx, prompt)
if err != nil {
return nil, fmt.Errorf("synthesize: %w", err)
}
// 4. Формируем источники
var sources []models.Source
for _, item := range deduped.Items {
sources = append(sources, models.Source{Title: item.Title, URL: item.URL})
}
return &models.ResearchResult{
ResearchID: req.ResearchID,
Status: "done",
Summary: summary,
Sources: sources,
}, nil
}
func (p *Pipeline) RunDeep(ctx context.Context, req *models.ResearchRequest) (*models.ResearchResult, error) {
log.Printf("[deep] starting research: %s", req.Query)
// 1. Поиск — 10-15 запросов
variations := generateQueryVariations(req.Query)
results, err := p.searchCli.SearchWithVariations(ctx, req.Query, variations, 15)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("ничего не найдено по запросу")
}
// 2. Дедупликация
deduped, err := dedup.Deduplicate(ctx, p.embedCli, results, 0.80)
if err != nil {
return nil, fmt.Errorf("dedup: %w", err)
}
if len(deduped.RawTexts) == 0 {
return nil, fmt.Errorf("не удалось обработать источники")
}
// 3. Первый проход LLM — определяем чего не хватает
prompt1 := synthesize.BuildGapPrompt(req.Query, deduped.RawTexts)
gapAnswer, err := p.llmCli.Chat(ctx, prompt1)
if err != nil {
return nil, fmt.Errorf("gap analysis: %w", err)
}
// 4. Второй раунд поиска по недостающим аспектам
gapVariations := extractFollowUpQueries(gapAnswer)
if len(gapVariations) > 0 {
log.Printf("[deep] follow-up queries: %v", gapVariations)
moreResults, err := p.searchCli.SearchWithVariations(ctx, req.Query, gapVariations, 5)
if err == nil && len(moreResults) > 0 {
moreDeduped, err := dedup.Deduplicate(ctx, p.embedCli, moreResults, 0.80)
if err == nil {
// Добавляем только новые URL
seen := make(map[string]bool)
for _, item := range deduped.Items {
seen[item.URL] = true
}
for _, item := range moreDeduped.Items {
if !seen[item.URL] {
deduped.Items = append(deduped.Items, item)
deduped.RawTexts = append(deduped.RawTexts, item.Title+". "+item.Content)
}
}
}
}
}
// 5. Финальный синтез
prompt2 := synthesize.BuildDeepResearchPrompt(req.Query, deduped.RawTexts, "")
finalReport, err := p.llmCli.Chat(ctx, prompt2)
if err != nil {
return nil, fmt.Errorf("final pass: %w", err)
}
// 6. Сохраняем отчёт в файл
reportPath := ""
if p.reportsDir != "" {
reportPath = fmt.Sprintf("%s/%s_%s.md", p.reportsDir, time.Now().Format("20060102"), req.ResearchID[:8])
if err := p.saveReport(reportPath, finalReport, deduped.Items, req.Query); err != nil {
log.Printf("[deep] save report error: %v", err)
reportPath = ""
}
}
var sources []models.Source
for _, item := range deduped.Items {
sources = append(sources, models.Source{Title: item.Title, URL: item.URL})
}
return &models.ResearchResult{
ResearchID: req.ResearchID,
Status: "done",
Summary: truncateText(finalReport, 1500),
ReportPath: reportPath,
Sources: sources,
}, nil
}
func generateQueryVariations(base string) []string {
return []string{
base + " обзор",
base + " анализ",
base + " исследование",
base + " последние новости",
base + " что это такое",
base + " проблемы и решения",
base + " будущее",
}
}
func extractFollowUpQueries(text string) []string {
// Простой парсер: ищем строки, которые выглядят как вопросы или темы
var queries []string
lines := strings.Split(text, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Ищем номерованные строки или буллеты
if strings.HasPrefix(line, "-") || strings.HasPrefix(line, "*") || (len(line) > 10 && len(line) < 200) {
// Убираем маркеры и добавляем в запросы
clean := strings.TrimPrefix(line, "-")
clean = strings.TrimPrefix(clean, "*")
clean = strings.TrimSpace(clean)
if clean != "" && len(clean) > 5 {
queries = append(queries, clean)
}
}
}
// Ограничиваем до 3 запросов
if len(queries) > 3 {
queries = queries[:3]
}
return queries
}
func (p *Pipeline) saveReport(path, report string, items []dedup.DedupItem, query string) error {
content := fmt.Sprintf("# %s\n\n%s\n\n## Источники\n\n", query, report)
for _, item := range items {
content += fmt.Sprintf("- [%s](%s)\n", item.Title, item.URL)
}
return os.WriteFile(path, []byte(content), 0644)
}
func truncateText(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
+104
View File
@@ -0,0 +1,104 @@
package search
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type SearXNGClient struct {
baseURL string
client *http.Client
}
type SearXNGResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
}
type SearXNGResponse struct {
Results []SearXNGResult `json:"results"`
}
func NewSearXNGClient(baseURL string) *SearXNGClient {
return &SearXNGClient{
baseURL: baseURL,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *SearXNGClient) Search(ctx context.Context, query string, limit int) ([]SearXNGResult, error) {
u, err := url.Parse(c.baseURL + "/search")
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
q := u.Query()
q.Set("q", query)
q.Set("format", "json")
q.Set("language", "ru-RU,en-US")
q.Set("categories", "general,news")
if limit > 0 {
q.Set("pageno", "1")
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "DeepRes/1.0")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("search request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("searxng returned %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var sr SearXNGResponse
if err := json.Unmarshal(body, &sr); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(sr.Results) > limit {
sr.Results = sr.Results[:limit]
}
return sr.Results, nil
}
// SearchWithVariations запускает несколько поисковых запросов с разными формулировками
func (c *SearXNGClient) SearchWithVariations(ctx context.Context, baseQuery string, variations []string, limit int) ([]SearXNGResult, error) {
seen := make(map[string]bool)
var allResults []SearXNGResult
queries := append([]string{baseQuery}, variations...)
for _, q := range queries {
results, err := c.Search(ctx, q, limit)
if err != nil {
// Логируем но продолжаем — один упавший запрос не ломает весь ресерч
continue
}
for _, r := range results {
if !seen[r.URL] {
seen[r.URL] = true
allResults = append(allResults, r)
}
}
}
return allResults, nil
}
+145
View File
@@ -0,0 +1,145 @@
package synthesize
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type LLMClient struct {
apiKey string
model string
baseURL string
client *http.Client
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type LLMRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float32 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type LLMResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"error,omitempty"`
}
func NewLLMClient(baseURL, apiKey, model string) *LLMClient {
return &LLMClient{
apiKey: apiKey,
model: model,
baseURL: baseURL,
client: &http.Client{Timeout: 120 * time.Second},
}
}
func (c *LLMClient) Chat(ctx context.Context, messages []Message) (string, error) {
reqBody := LLMRequest{
Model: c.model,
Messages: messages,
Temperature: 0.7,
MaxTokens: 3000,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal llm request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("llm call: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read llm response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("llm returned %d: %s", resp.StatusCode, string(respBody))
}
var lr LLMResponse
if err := json.Unmarshal(respBody, &lr); err != nil {
return "", fmt.Errorf("parse llm response: %w", err)
}
if lr.Error != nil {
return "", fmt.Errorf("llm error %d: %s", lr.Error.Code, lr.Error.Message)
}
if len(lr.Choices) == 0 {
return "", fmt.Errorf("empty llm response: %s", string(respBody))
}
return lr.Choices[0].Message.Content, nil
}
// ---- Промпты ----
func BuildFastResearchPrompt(query string, sources []string) []Message {
srcText := strings.Join(sources, "\n\n---\n\n")
return []Message{
{
Role: "system",
Content: "Ты — DeepRes, AI-исследователь. Твоя задача: на основе предоставленных источников написать структурированную выжимку по теме. Выдели ключевые факты, противоположные точки зрения, практические выводы. Если источники противоречат друг другу — укажи это. Пиши на русском, если тема на русском. Формат: кратко, ёмко, по делу.",
},
{
Role: "user",
Content: fmt.Sprintf("Тема: %s\n\nИсточники:\n%s\n\nНапиши выжимку на 3-5 абзацев с ключевыми выводами и ссылками на источники.", query, srcText),
},
}
}
func BuildDeepResearchPrompt(query string, sources []string, followUpQ string) []Message {
srcText := strings.Join(sources, "\n\n---\n\n")
messages := []Message{
{
Role: "system",
Content: "Ты — DeepRes, AI-исследователь. Напиши ПОЛНЫЙ отчёт по теме на основе источников. Структура: введение, ключевые находки, анализ, противоположные точки зрения, выводы и рекомендации. Отметь области, по которым недостаточно информации.",
},
{
Role: "user",
Content: fmt.Sprintf("Тема: %s\n\nИсточники:\n%s", query, srcText),
},
}
if followUpQ != "" {
messages = append(messages, Message{Role: "user", Content: followUpQ})
}
return messages
}
func BuildGapPrompt(query string, sources []string) []Message {
srcText := strings.Join(sources, "\n\n---\n\n")
return []Message{
{
Role: "system",
Content: "Ты — аналитик, который ищет неизведанные области. Проанализируй предоставленные источники и найди:\n1) Какие аспекты темы НЕ освещены?\n2) Где источники противоречат друг другу?\n3) Какие вопросы остаются открытыми?\n4) Что было бы важно изучить дальше?",
},
{
Role: "user",
Content: fmt.Sprintf("Тема: %s\n\nИсточники:\n%s", query, srcText),
},
}
}