220 lines
6.9 KiB
Go
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]) + "..."
|
|
}
|