Files

220 lines
6.9 KiB
Go

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]) + "..."
}