Initial commit: DeepRes Go backend — bot + worker + embed service
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# ============= DeepRes Configuration =============
|
||||
|
||||
# Telegram Bot Token (от @BotFather)
|
||||
TELEGRAM_TOKEN=
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgres://deepres:deepres@localhost:5432/deepres?sslmode=disable
|
||||
|
||||
# NATS
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# SearXNG
|
||||
SEARXNG_URL=http://localhost:4000
|
||||
|
||||
# OpenRouter AI (для синтеза)
|
||||
OPENROUTER_API_KEY=
|
||||
LLM_MODEL=deepseek/deepseek-v4-flash
|
||||
|
||||
# Embeddings (E5-small HTTP-сервер, например https://github.com/emil/embed-server)
|
||||
EMBED_URL=http://localhost:8081
|
||||
|
||||
# Цены в Telegram Stars
|
||||
FAST_COST=1
|
||||
DEEP_COST=5
|
||||
FREE_FAST_PER_DAY=3
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Binaries
|
||||
bot
|
||||
worker
|
||||
*.exe
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
/tmp/deepres-*
|
||||
|
||||
# Reports
|
||||
reports/
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
*.sum
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
reports/
|
||||
@@ -0,0 +1,33 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: deepres
|
||||
POSTGRES_USER: deepres
|
||||
POSTGRES_PASSWORD: deepres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
nats:
|
||||
image: nats:latest
|
||||
ports:
|
||||
- "4222:4222"
|
||||
restart: unless-stopped
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
ports:
|
||||
- "4000:8080"
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:4000
|
||||
volumes:
|
||||
- ./searxng/settings.yml:/etc/searxng/settings.yml:z
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8081
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"]
|
||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
E5-small-v2 embedding server — OpenAI-compatible /v1/embeddings endpoint.
|
||||
Minimal, CPU-only, ~100MB RAM.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from sentence_transformers import SentenceTransformer
|
||||
import numpy as np
|
||||
|
||||
app = FastAPI()
|
||||
model = SentenceTransformer("intfloat/e5-small-v2", device="cpu")
|
||||
|
||||
|
||||
class EmbedRequest(BaseModel):
|
||||
model: str = "intfloat/e5-small-v2"
|
||||
input: list[str]
|
||||
|
||||
|
||||
class EmbedData(BaseModel):
|
||||
object: str = "embedding"
|
||||
index: int
|
||||
embedding: list[float]
|
||||
|
||||
|
||||
class EmbedResponse(BaseModel):
|
||||
object: str = "list"
|
||||
data: list[EmbedData]
|
||||
model: str
|
||||
|
||||
|
||||
@app.post("/v1/embeddings")
|
||||
def embed(req: EmbedRequest):
|
||||
# E5 requires "query: " or "passage: " prefix
|
||||
prefixed = [f"passage: {t}" if "\n" not in t else t for t in req.input]
|
||||
embs = model.encode(prefixed, normalize_embeddings=True, show_progress_bar=False)
|
||||
data = [
|
||||
EmbedData(index=i, embedding=emb.tolist())
|
||||
for i, emb in enumerate(embs)
|
||||
]
|
||||
return EmbedResponse(data=data, model=req.model)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8081)
|
||||
@@ -0,0 +1,3 @@
|
||||
sentence-transformers>=3.0
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
@@ -0,0 +1,23 @@
|
||||
module github.com/emil/deepres
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/nats-io/nats.go v1.41.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- DeepRes initial schema
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
username TEXT DEFAULT '',
|
||||
name TEXT DEFAULT '',
|
||||
stars_balance INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS researches (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
tg_id BIGINT REFERENCES users(tg_id) ON DELETE CASCADE,
|
||||
query TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('fast', 'deep')),
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
stars_cost INT DEFAULT 0,
|
||||
summary TEXT,
|
||||
report_path TEXT,
|
||||
sources JSONB,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
tg_id BIGINT REFERENCES users(tg_id) ON DELETE CASCADE,
|
||||
amount INT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('purchase', 'research', 'bonus', 'refund')),
|
||||
description TEXT DEFAULT '',
|
||||
telegram_charge_id TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_researches_tg_id ON researches(tg_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_researches_created_at ON researches(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_tg_id ON transactions(tg_id);
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd ~/Desktop/DeepRes
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
exec stdbuf -oL ./bot > /tmp/deepres-bot.log 2>&1
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd ~/Desktop/DeepRes
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
exec stdbuf -oL ./worker > /tmp/deepres-worker.log 2>&1
|
||||
@@ -0,0 +1,28 @@
|
||||
use_default_settings: true
|
||||
general:
|
||||
instance_name: "DeepRes"
|
||||
enable_metrics: false
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
server:
|
||||
port: 8080
|
||||
bind_address: "0.0.0.0"
|
||||
secret_key: "deepres-secret-change-in-production"
|
||||
method: "POST"
|
||||
ui:
|
||||
default_locale: ru
|
||||
search_on_category_select: true
|
||||
hotkeys: "default"
|
||||
engines:
|
||||
- name: duckduckgo
|
||||
disabled: false
|
||||
- name: wikipedia
|
||||
disabled: false
|
||||
- name: wikidata
|
||||
disabled: false
|
||||
- name: stackoverflow
|
||||
disabled: false
|
||||
Reference in New Issue
Block a user