feat: CI/CD pipeline + Learning/Medicine/Travel services
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped

- Add Gitea Actions workflow for automated build & deploy
- Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc
- Update kustomization for localhost:5000 registry
- Add ingress for gooseek.ru and api.gooseek.ru
- Learning cabinet with onboarding, courses, sandbox integration
- Medicine service with symptom analysis and doctor matching
- Travel service with itinerary planning
- Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner)

Made-with: Cursor
This commit is contained in:
home
2026-03-02 20:25:44 +03:00
parent 08bd41e75c
commit ab48a0632b
92 changed files with 15562 additions and 2198 deletions

View File

@@ -0,0 +1,836 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/session"
"github.com/gooseek/backend/internal/types"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
type LearningIntent string
const (
IntentTaskGenerate LearningIntent = "task_generate"
IntentVerify LearningIntent = "verify"
IntentPlan LearningIntent = "plan"
IntentQuiz LearningIntent = "quiz"
IntentExplain LearningIntent = "explain"
IntentQuestion LearningIntent = "question"
IntentOnboarding LearningIntent = "onboarding"
IntentProgress LearningIntent = "progress"
)
type LearningBrief struct {
Intent LearningIntent `json:"intent"`
Topic string `json:"topic"`
Difficulty string `json:"difficulty"`
Language string `json:"language"`
TaskType string `json:"task_type"`
SpecificRequest string `json:"specific_request"`
CodeSubmitted string `json:"code_submitted"`
NeedsContext bool `json:"needs_context"`
}
type LearningDraft struct {
Brief *LearningBrief
ProfileContext string
CourseContext string
PlanContext string
TaskContext string
GeneratedTask *GeneratedTask
GeneratedQuiz *GeneratedQuiz
GeneratedPlan *GeneratedPlan
Evaluation *TaskEvaluation
Explanation string
Phase string
}
type GeneratedTask struct {
Title string `json:"title"`
Difficulty string `json:"difficulty"`
EstimatedMin int `json:"estimated_minutes"`
Description string `json:"description"`
Requirements []string `json:"requirements"`
Acceptance []string `json:"acceptance_criteria"`
Hints []string `json:"hints"`
SandboxSetup string `json:"sandbox_setup"`
VerifyCmd string `json:"verify_command"`
StarterCode string `json:"starter_code"`
TestCode string `json:"test_code"`
SkillsTrained []string `json:"skills_trained"`
}
type GeneratedQuiz struct {
Title string `json:"title"`
Questions []QuizQuestion `json:"questions"`
}
type QuizQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
Correct int `json:"correct_index"`
Explain string `json:"explanation"`
}
type GeneratedPlan struct {
Modules []PlanModule `json:"modules"`
TotalHours int `json:"total_hours"`
DifficultyAdjusted string `json:"difficulty_adjusted"`
PersonalizationNote string `json:"personalization_notes"`
}
type PlanModule struct {
Index int `json:"index"`
Title string `json:"title"`
Description string `json:"description"`
Skills []string `json:"skills"`
EstimatedHrs int `json:"estimated_hours"`
PracticeFocus string `json:"practice_focus"`
TaskCount int `json:"task_count"`
}
type TaskEvaluation struct {
Score int `json:"score"`
MaxScore int `json:"max_score"`
Passed bool `json:"passed"`
Strengths []string `json:"strengths"`
Issues []string `json:"issues"`
Suggestions []string `json:"suggestions"`
CodeQuality string `json:"code_quality"`
}
// RunLearningOrchestrator is the multi-agent pipeline for learning chat.
// Flow: Intent Classifier → Context Collector → Specialized Agent → Widget Emission.
func RunLearningOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error {
researchBlockID := uuid.New().String()
sess.EmitBlock(types.NewResearchBlock(researchBlockID))
emitPhase(sess, researchBlockID, "reasoning", "Анализирую запрос...")
// --- Phase 1: Intent Classification via LLM ---
brief, err := runLearningPlanner(ctx, input)
if err != nil {
log.Printf("[learning] planner error: %v, falling back to keyword", err)
brief = fallbackClassify(input.FollowUp)
}
log.Printf("[learning] intent=%s topic=%q difficulty=%s", brief.Intent, brief.Topic, brief.Difficulty)
draft := &LearningDraft{
Brief: brief,
Phase: "collecting",
}
// --- Phase 2: Parallel Context Collection ---
emitPhase(sess, researchBlockID, "searching", phaseSearchLabel(brief.Intent))
var mu sync.Mutex
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
profile := extractProfileContext(input)
mu.Lock()
draft.ProfileContext = profile
mu.Unlock()
return nil
})
g.Go(func() error {
course := extractCourseContext(input)
mu.Lock()
draft.CourseContext = course
mu.Unlock()
return nil
})
g.Go(func() error {
plan := extractPlanContext(input)
mu.Lock()
draft.PlanContext = plan
mu.Unlock()
return nil
})
_ = g.Wait()
// --- Phase 3: Specialized Agent based on intent ---
emitPhase(sess, researchBlockID, "reasoning", phaseAgentLabel(brief.Intent))
switch brief.Intent {
case IntentTaskGenerate:
task, err := runTaskGeneratorAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] task generator error: %v", err)
} else {
draft.GeneratedTask = task
}
case IntentVerify:
eval, err := runCodeReviewAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] code review error: %v", err)
} else {
draft.Evaluation = eval
}
case IntentQuiz:
quiz, err := runQuizGeneratorAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] quiz generator error: %v", err)
} else {
draft.GeneratedQuiz = quiz
}
case IntentPlan:
plan, err := runPlanBuilderAgent(gctx, input, draft)
if err != nil {
log.Printf("[learning] plan builder error: %v", err)
} else {
draft.GeneratedPlan = plan
}
}
sess.EmitResearchComplete()
// --- Phase 4: Generate response text + emit widgets ---
emitLearningResponse(ctx, sess, input, draft)
sess.EmitEnd()
return nil
}
// --- Phase 1: LLM-based Intent Classification ---
func runLearningPlanner(ctx context.Context, input OrchestratorInput) (*LearningBrief, error) {
plannerCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — классификатор запросов в образовательной платформе. Определи намерение ученика.
Сообщение ученика: "%s"
Контекст (если есть): %s
Определи intent и параметры. Ответь строго JSON:
{
"intent": "task_generate|verify|plan|quiz|explain|question|onboarding|progress",
"topic": "тема если определена",
"difficulty": "beginner|intermediate|advanced|expert",
"language": "язык программирования если есть",
"task_type": "code|test|review|deploy|debug|refactor|design",
"specific_request": "что конкретно просит",
"code_submitted": "код если ученик прислал код для проверки",
"needs_context": true
}
Правила:
- "задание/задачу/практику/упражнение" → task_generate
- "проверь/оцени/ревью/посмотри код" → verify
- "план/программу/roadmap/что учить" → plan
- "тест/квиз/экзамен" → quiz
- "объясни/расскажи/как работает" → explain
- "прогресс/результаты/статистика" → progress
- Если код в сообщении и просьба проверить → verify + code_submitted
- По умолчанию → question`, input.FollowUp, truncate(input.Config.SystemInstructions, 500))
result, err := input.Config.LLM.GenerateText(plannerCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var brief LearningBrief
if err := json.Unmarshal([]byte(jsonStr), &brief); err != nil {
return nil, fmt.Errorf("parse brief: %w", err)
}
if brief.Intent == "" {
brief.Intent = IntentQuestion
}
return &brief, nil
}
func fallbackClassify(query string) *LearningBrief {
q := strings.ToLower(query)
brief := &LearningBrief{Intent: IntentQuestion, NeedsContext: true}
switch {
case containsAny(q, "задание", "задачу", "практик", "упражнение", "тренир", "дай задач"):
brief.Intent = IntentTaskGenerate
case containsAny(q, "провер", "оцени", "ревью", "посмотри код", "правильно ли", "code review"):
brief.Intent = IntentVerify
case containsAny(q, "план", "програм", "roadmap", "чему учить", "маршрут обучения"):
brief.Intent = IntentPlan
case containsAny(q, "тест", "экзамен", "квиз", "проверочн"):
brief.Intent = IntentQuiz
case containsAny(q, "объясни", "расскажи", "как работает", "что такое", "зачем нужн"):
brief.Intent = IntentExplain
case containsAny(q, "прогресс", "результат", "статистик", "сколько сделал"):
brief.Intent = IntentProgress
}
return brief
}
// --- Phase 2: Context Extraction ---
func extractProfileContext(input OrchestratorInput) string {
if input.Config.UserMemory != "" {
return input.Config.UserMemory
}
return ""
}
func extractCourseContext(input OrchestratorInput) string {
si := input.Config.SystemInstructions
if idx := strings.Index(si, "Текущий курс:"); idx >= 0 {
end := strings.Index(si[idx:], "\n")
if end > 0 {
return si[idx : idx+end]
}
return si[idx:]
}
return ""
}
func extractPlanContext(input OrchestratorInput) string {
si := input.Config.SystemInstructions
if idx := strings.Index(si, "План обучения:"); idx >= 0 {
return si[idx:]
}
return ""
}
// --- Phase 3: Specialized Agents ---
func runTaskGeneratorAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedTask, error) {
agentCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
contextBlock := buildTaskGenContext(draft)
prompt := fmt.Sprintf(`Ты — ведущий разработчик в крупной IT-компании в РФ. Генерируй боевое практическое задание.
%s
Тема: %s
Сложность: %s
Язык: %s
Тип: %s
ТРЕБОВАНИЯ К ЗАДАНИЮ:
1. Задание должно быть РЕАЛЬНЫМ — как задача из production проекта в российской IT-компании
2. Чёткая постановка: что сделать, какие входные данные, что на выходе
3. Обязательно: тесты, обработка ошибок, edge cases
4. Код-стайл: линтеры, форматирование, документация
5. Если backend: REST API, middleware, валидация, логирование
6. Если frontend: компоненты, стейт-менеджмент, адаптивность
7. Starter code должен компилироваться/запускаться
8. Verify command должен реально проверять решение
Ответь строго JSON:
{
"title": "Название задания",
"difficulty": "beginner|intermediate|advanced",
"estimated_minutes": 30,
"description": "Подробное описание задачи в markdown",
"requirements": ["Требование 1", "Требование 2"],
"acceptance_criteria": ["Критерий приёмки 1", "Критерий приёмки 2"],
"hints": ["Подсказка 1 (скрытая)"],
"sandbox_setup": "команды для подготовки окружения (apt install, npm init, etc.)",
"verify_command": "команда для проверки решения (go test ./... или npm test)",
"starter_code": "начальный код проекта",
"test_code": "код тестов для автопроверки",
"skills_trained": ["навык1", "навык2"]
}`, contextBlock, orDefault(draft.Brief.Topic, "по текущему курсу"),
orDefault(draft.Brief.Difficulty, "intermediate"),
orDefault(draft.Brief.Language, "go"),
orDefault(draft.Brief.TaskType, "code"))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var task GeneratedTask
if err := json.Unmarshal([]byte(jsonStr), &task); err != nil {
return nil, fmt.Errorf("parse task: %w", err)
}
return &task, nil
}
func runCodeReviewAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*TaskEvaluation, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
codeToReview := draft.Brief.CodeSubmitted
if codeToReview == "" {
codeToReview = input.Config.FileContext
}
if codeToReview == "" {
for _, m := range input.ChatHistory {
if m.Role == "user" && (strings.Contains(m.Content, "```") || strings.Contains(m.Content, "func ") || strings.Contains(m.Content, "function ")) {
codeToReview = m.Content
}
}
}
if codeToReview == "" {
return &TaskEvaluation{
Score: 0,
MaxScore: 100,
Issues: []string{"Код для проверки не найден. Пришлите код в сообщении или загрузите файл."},
}, nil
}
prompt := fmt.Sprintf(`Ты — senior code reviewer в крупной IT-компании. Проведи строгое ревью кода.
Код для проверки:
%s
Контекст задания: %s
Оцени по критериям:
1. Корректность (работает ли код правильно)
2. Код-стайл (форматирование, именование, идиоматичность)
3. Обработка ошибок (edge cases, panic recovery, валидация)
4. Тесты (есть ли, покрытие, качество)
5. Безопасность (SQL injection, XSS, утечки данных)
6. Производительность (O-нотация, утечки памяти, N+1)
Ответь строго JSON:
{
"score": 75,
"max_score": 100,
"passed": true,
"strengths": ["Что хорошо"],
"issues": ["Что исправить"],
"suggestions": ["Рекомендации по улучшению"],
"code_quality": "good|acceptable|needs_work|poor"
}`, truncate(codeToReview, 6000), truncate(draft.TaskContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var eval TaskEvaluation
if err := json.Unmarshal([]byte(jsonStr), &eval); err != nil {
return nil, fmt.Errorf("parse eval: %w", err)
}
if eval.MaxScore == 0 {
eval.MaxScore = 100
}
return &eval, nil
}
func runQuizGeneratorAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedQuiz, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — методолог обучения. Создай тест для проверки знаний.
Тема: %s
Сложность: %s
Контекст курса: %s
Создай 5 вопросов. Вопросы должны проверять ПОНИМАНИЕ, а не зубрёжку.
Включи: практические сценарии, код-сниппеты, архитектурные решения.
Ответь строго JSON:
{
"title": "Название теста",
"questions": [
{
"question": "Текст вопроса (может содержать код в markdown)",
"options": ["Вариант A", "Вариант B", "Вариант C", "Вариант D"],
"correct_index": 0,
"explanation": "Почему этот ответ правильный"
}
]
}`, orDefault(draft.Brief.Topic, "текущий модуль"),
orDefault(draft.Brief.Difficulty, "intermediate"),
truncate(draft.CourseContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var quiz GeneratedQuiz
if err := json.Unmarshal([]byte(jsonStr), &quiz); err != nil {
return nil, fmt.Errorf("parse quiz: %w", err)
}
return &quiz, nil
}
func runPlanBuilderAgent(ctx context.Context, input OrchestratorInput, draft *LearningDraft) (*GeneratedPlan, error) {
agentCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT. Построй персональный план обучения.
Профиль ученика: %s
Текущий курс: %s
Текущий прогресс: %s
Требования:
1. Минимум теории, максимум боевой практики
2. Каждый модуль = практическое задание из реального проекта
3. Прогрессия сложности от текущего уровня ученика
4. Учитывай уже пройденные темы
5. Задания как в российских IT-компаниях
Ответь строго JSON:
{
"modules": [
{
"index": 0,
"title": "Название модуля",
"description": "Что изучаем и делаем",
"skills": ["навык1"],
"estimated_hours": 4,
"practice_focus": "Конкретная практическая задача",
"task_count": 3
}
],
"total_hours": 40,
"difficulty_adjusted": "intermediate",
"personalization_notes": "Как план адаптирован под ученика"
}`, truncate(draft.ProfileContext, 1500),
truncate(draft.CourseContext, 1000),
truncate(draft.PlanContext, 1000))
result, err := input.Config.LLM.GenerateText(agentCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, err
}
jsonStr := extractJSONFromLLM(result)
var plan GeneratedPlan
if err := json.Unmarshal([]byte(jsonStr), &plan); err != nil {
return nil, fmt.Errorf("parse plan: %w", err)
}
return &plan, nil
}
// --- Phase 4: Response Generation + Widget Emission ---
func emitLearningResponse(ctx context.Context, sess *session.Session, input OrchestratorInput, draft *LearningDraft) {
// Emit structured widgets first
emitLearningWidgets(sess, draft)
// Then stream the conversational response
textBlockID := uuid.New().String()
sess.EmitBlock(types.NewTextBlock(textBlockID, ""))
systemPrompt := buildLearningResponsePrompt(input, draft)
messages := make([]llm.Message, 0, len(input.ChatHistory)+3)
messages = append(messages, llm.Message{Role: "system", Content: systemPrompt})
for _, m := range input.ChatHistory {
messages = append(messages, m)
}
messages = append(messages, llm.Message{Role: "user", Content: input.FollowUp})
streamCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
ch, err := input.Config.LLM.StreamText(streamCtx, llm.StreamRequest{Messages: messages})
if err != nil {
sess.UpdateBlock(textBlockID, []session.Patch{
{Op: "replace", Path: "/data", Value: fmt.Sprintf("Ошибка: %v", err)},
})
return
}
var fullContent strings.Builder
for chunk := range ch {
if chunk.ContentChunk == "" {
continue
}
fullContent.WriteString(chunk.ContentChunk)
sess.UpdateBlock(textBlockID, []session.Patch{
{Op: "replace", Path: "/data", Value: fullContent.String()},
})
}
}
func emitLearningWidgets(sess *session.Session, draft *LearningDraft) {
if draft.GeneratedTask != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_task", map[string]interface{}{
"status": "ready",
"title": draft.GeneratedTask.Title,
"difficulty": draft.GeneratedTask.Difficulty,
"estimated": draft.GeneratedTask.EstimatedMin,
"requirements": draft.GeneratedTask.Requirements,
"acceptance": draft.GeneratedTask.Acceptance,
"hints": draft.GeneratedTask.Hints,
"verify_cmd": draft.GeneratedTask.VerifyCmd,
"starter_code": draft.GeneratedTask.StarterCode,
"test_code": draft.GeneratedTask.TestCode,
"skills": draft.GeneratedTask.SkillsTrained,
}))
}
if draft.Evaluation != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_evaluation", map[string]interface{}{
"score": draft.Evaluation.Score,
"max_score": draft.Evaluation.MaxScore,
"passed": draft.Evaluation.Passed,
"strengths": draft.Evaluation.Strengths,
"issues": draft.Evaluation.Issues,
"suggestions": draft.Evaluation.Suggestions,
"quality": draft.Evaluation.CodeQuality,
}))
}
if draft.GeneratedQuiz != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_quiz", map[string]interface{}{
"title": draft.GeneratedQuiz.Title,
"questions": draft.GeneratedQuiz.Questions,
"count": len(draft.GeneratedQuiz.Questions),
}))
}
if draft.GeneratedPlan != nil {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_plan", map[string]interface{}{
"modules": draft.GeneratedPlan.Modules,
"total_hours": draft.GeneratedPlan.TotalHours,
"difficulty": draft.GeneratedPlan.DifficultyAdjusted,
"notes": draft.GeneratedPlan.PersonalizationNote,
}))
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), "learning_progress", map[string]interface{}{
"phase": "idle",
"intent": string(draft.Brief.Intent),
"timestamp": time.Now().Format(time.RFC3339),
}))
}
func buildLearningResponsePrompt(input OrchestratorInput, draft *LearningDraft) string {
var sb strings.Builder
sb.WriteString(`Ты — AI-наставник на платформе GooSeek Education. Ведёшь обучение через чат.
СТИЛЬ:
- Минимум теории, максимум практики. Объясняй кратко, по делу.
- Задания «боевые» — как на реальных проектах в российских IT-компаниях.
- Конструктивная обратная связь: что хорошо + что улучшить.
- Адаптируй сложность под ученика.
- Русский язык. Markdown для форматирования.
- Будь строгим но справедливым ментором, не «добрым учителем».
`)
switch draft.Brief.Intent {
case IntentTaskGenerate:
if draft.GeneratedTask != nil {
taskJSON, _ := json.Marshal(draft.GeneratedTask)
sb.WriteString("СГЕНЕРИРОВАННОЕ ЗАДАНИЕ (уже отправлено как виджет, не дублируй полностью):\n")
sb.WriteString(string(taskJSON))
sb.WriteString("\n\nПредставь задание ученику: кратко опиши суть, мотивируй, дай контекст зачем это нужно в реальной работе. НЕ копируй JSON — виджет уже показан.\n\n")
} else {
sb.WriteString("Задание не удалось сгенерировать. Предложи ученику уточнить тему или сложность.\n\n")
}
case IntentVerify:
if draft.Evaluation != nil {
evalJSON, _ := json.Marshal(draft.Evaluation)
sb.WriteString("РЕЗУЛЬТАТ РЕВЬЮ (уже отправлен как виджет):\n")
sb.WriteString(string(evalJSON))
sb.WriteString("\n\nДай развёрнутую обратную связь: разбери каждый issue, объясни почему это проблема, покажи как исправить с примерами кода. Похвали за strengths.\n\n")
}
case IntentQuiz:
if draft.GeneratedQuiz != nil {
sb.WriteString("Тест сгенерирован и показан как виджет. Кратко представь тест, объясни что проверяется.\n\n")
}
case IntentPlan:
if draft.GeneratedPlan != nil {
planJSON, _ := json.Marshal(draft.GeneratedPlan)
sb.WriteString("ПЛАН ОБУЧЕНИЯ (уже отправлен как виджет):\n")
sb.WriteString(string(planJSON))
sb.WriteString("\n\nПредставь план: объясни логику прогрессии, почему именно такие модули, что ученик получит в итоге.\n\n")
}
case IntentProgress:
sb.WriteString("Покажи прогресс ученика на основе контекста. Если данных нет — предложи начать с задания или теста.\n\n")
}
if draft.ProfileContext != "" {
sb.WriteString("ПРОФИЛЬ УЧЕНИКА:\n")
sb.WriteString(truncate(draft.ProfileContext, 1500))
sb.WriteString("\n\n")
}
if draft.CourseContext != "" {
sb.WriteString("ТЕКУЩИЙ КУРС:\n")
sb.WriteString(draft.CourseContext)
sb.WriteString("\n\n")
}
if draft.PlanContext != "" {
sb.WriteString("ПЛАН ОБУЧЕНИЯ:\n")
sb.WriteString(truncate(draft.PlanContext, 2000))
sb.WriteString("\n\n")
}
if input.Config.FileContext != "" {
sb.WriteString("КОД/ФАЙЛЫ УЧЕНИКА:\n")
sb.WriteString(truncate(input.Config.FileContext, 4000))
sb.WriteString("\n\n")
}
return sb.String()
}
// --- Helpers ---
func emitPhase(sess *session.Session, blockID, stepType, text string) {
step := types.ResearchSubStep{ID: uuid.New().String(), Type: stepType}
switch stepType {
case "reasoning":
step.Reasoning = text
case "searching":
step.Searching = []string{text}
}
sess.UpdateBlock(blockID, []session.Patch{
{Op: "replace", Path: "/data/subSteps", Value: []types.ResearchSubStep{step}},
})
}
func phaseSearchLabel(intent LearningIntent) string {
switch intent {
case IntentTaskGenerate:
return "Проектирую практическое задание..."
case IntentVerify:
return "Анализирую код..."
case IntentPlan:
return "Строю план обучения..."
case IntentQuiz:
return "Создаю тест..."
case IntentExplain:
return "Готовлю объяснение..."
case IntentProgress:
return "Собираю статистику..."
default:
return "Готовлю ответ..."
}
}
func phaseAgentLabel(intent LearningIntent) string {
switch intent {
case IntentTaskGenerate:
return "Генерирую боевое задание..."
case IntentVerify:
return "Провожу code review..."
case IntentPlan:
return "Адаптирую план под профиль..."
case IntentQuiz:
return "Составляю вопросы..."
default:
return "Формирую ответ..."
}
}
func buildTaskGenContext(draft *LearningDraft) string {
var parts []string
if draft.ProfileContext != "" {
parts = append(parts, "Профиль ученика: "+truncate(draft.ProfileContext, 800))
}
if draft.CourseContext != "" {
parts = append(parts, "Курс: "+draft.CourseContext)
}
if draft.PlanContext != "" {
parts = append(parts, "План: "+truncate(draft.PlanContext, 800))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n")
}
func extractJSONFromLLM(response string) string {
if strings.Contains(response, "```json") {
start := strings.Index(response, "```json") + 7
end := strings.Index(response[start:], "```")
if end > 0 {
return strings.TrimSpace(response[start : start+end])
}
}
if strings.Contains(response, "```") {
start := strings.Index(response, "```") + 3
if nl := strings.Index(response[start:], "\n"); nl >= 0 {
start += nl + 1
}
end := strings.Index(response[start:], "```")
if end > 0 {
candidate := strings.TrimSpace(response[start : start+end])
if len(candidate) > 2 && candidate[0] == '{' {
return candidate
}
}
}
depth := 0
startIdx := -1
for i, ch := range response {
if ch == '{' {
if depth == 0 {
startIdx = i
}
depth++
} else if ch == '}' {
depth--
if depth == 0 && startIdx >= 0 {
candidate := response[startIdx : i+1]
if len(candidate) > 10 {
return candidate
}
}
}
}
return "{}"
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func orDefault(val, def string) string {
if val == "" {
return def
}
return val
}

View File

@@ -54,6 +54,7 @@ type OrchestratorConfig struct {
TravelSvcURL string
TravelPayoutsToken string
TravelPayoutsMarker string
PhotoCache *PhotoCacheService
}
type DigestResponse struct {
@@ -94,6 +95,10 @@ func RunOrchestrator(ctx context.Context, sess *session.Session, input Orchestra
return RunTravelOrchestrator(ctx, sess, input)
}
if input.Config.AnswerMode == "learning" || input.Config.LearningMode {
return RunLearningOrchestrator(ctx, sess, input)
}
detectedLang := detectLanguage(input.FollowUp)
isArticleSummary := strings.HasPrefix(strings.TrimSpace(input.FollowUp), "Summary: ")

View File

@@ -22,13 +22,25 @@ type TravelContext struct {
BestTimeInfo string `json:"bestTimeInfo,omitempty"`
}
type DailyForecast struct {
Date string `json:"date"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Icon string `json:"icon"`
RainChance string `json:"rainChance"`
Wind string `json:"wind,omitempty"`
Tip string `json:"tip,omitempty"`
}
type WeatherAssessment struct {
Summary string `json:"summary"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Clothing string `json:"clothing"`
RainChance string `json:"rainChance"`
Summary string `json:"summary"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Clothing string `json:"clothing"`
RainChance string `json:"rainChance"`
DailyForecast []DailyForecast `json:"dailyForecast,omitempty"`
}
type SafetyAssessment struct {
@@ -87,7 +99,6 @@ func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *
dest := strings.Join(brief.Destinations, ", ")
currentYear := time.Now().Format("2006")
currentMonth := time.Now().Format("01")
monthNames := map[string]string{
"01": "январь", "02": "февраль", "03": "март",
@@ -95,10 +106,25 @@ func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *
"07": "июль", "08": "август", "09": "сентябрь",
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
}
month := monthNames[currentMonth]
tripMonth := time.Now().Format("01")
if brief.StartDate != "" {
if t, err := time.Parse("2006-01-02", brief.StartDate); err == nil {
tripMonth = t.Format("01")
}
}
month := monthNames[tripMonth]
dateRange := ""
if brief.StartDate != "" && brief.EndDate != "" {
dateRange = fmt.Sprintf("%s — %s", brief.StartDate, brief.EndDate)
} else if brief.StartDate != "" {
dateRange = brief.StartDate
}
queries := []string{
fmt.Sprintf("погода %s %s %s прогноз", dest, month, currentYear),
fmt.Sprintf("погода %s %s %s прогноз по дням", dest, month, currentYear),
fmt.Sprintf("прогноз погоды %s %s на 14 дней", dest, dateRange),
fmt.Sprintf("безопасность туристов %s %s", dest, currentYear),
fmt.Sprintf("ограничения %s туризм %s", dest, currentYear),
fmt.Sprintf("что нужно знать туристу %s %s", dest, currentYear),
@@ -154,20 +180,40 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
dest := strings.Join(brief.Destinations, ", ")
currentDate := time.Now().Format("2006-01-02")
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени текущую обстановку в %s для поездки %s — %s.
Сегодня: %s.
tripDays := computeTripDays(brief.StartDate, brief.EndDate)
dailyForecastNote := ""
if tripDays > 0 {
dailyForecastNote = fmt.Sprintf(`
ВАЖНО: Поездка длится %d дней (%s — %s). Составь прогноз погоды НА КАЖДЫЙ ДЕНЬ поездки.
В "dailyForecast" должно быть ровно %d элементов — по одному на каждый день.`, tripDays, brief.StartDate, brief.EndDate, tripDays)
}
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени обстановку в %s для поездки %s — %s.
Сегодня: %s.
%s
%s
Верни ТОЛЬКО JSON (без текста):
{
"weather": {
"summary": "Краткое описание погоды на период поездки",
"tempMin": число_градусов_минимум,
"tempMax": число_градусов_максимум,
"conditions": "солнечно/облачно/дождливо/снежно",
"clothing": "Что надеть: конкретные рекомендации",
"rainChance": "низкая/средняя/высокая"
"summary": "Общее описание погоды на весь период поездки",
"tempMin": число_минимумаесь_период,
"tempMax": числоаксимумаесь_период,
"conditions": "преобладающие условия: солнечно/облачно/переменная облачность/дождливо/снежно",
"clothing": "Что надеть: конкретные рекомендации по одежде",
"rainChance": "низкая/средняя/высокая",
"dailyForecast": [
{
"date": "YYYY-MM-DD",
"tempMin": число,
"tempMax": число,
"conditions": "солнечно/облачно/дождь/гроза/снег/туман/переменная облачность",
"icon": "sun/cloud/cloud-sun/rain/storm/snow/fog/wind",
"rainChance": "низкая/средняя/высокая",
"wind": "слабый/умеренный/сильный",
"tip": "Краткий совет на этот день (необязательно, только если есть что сказать)"
}
]
},
"safety": {
"level": "safe/caution/warning/danger",
@@ -190,27 +236,35 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
}
Правила:
- Используй ТОЛЬКО актуальные данные %s года
- weather: реальный прогноз на период поездки, не среднегодовые значения
- safety: объективная оценка, не преувеличивай опасности
- Используй актуальные данные %s года и данные из поиска
- dailyForecast: прогноз НА КАЖДЫЙ ДЕНЬ поездки с конкретными температурами и условиями
- Если точный прогноз недоступен — используй климатические данные для этого периода, но старайся варьировать по дням реалистично
- icon: одно из значений sun/cloud/cloud-sun/rain/storm/snow/fog/wind
- weather.summary: общее описание, упомяни если ожидаются дождливые дни
- safety: объективная оценка, не преувеличивай
- restrictions: визовые требования, медицинские ограничения, локальные правила
- tips: 3-5 практичных советов для туриста
- Если данных нет — используй свои знания о регионе, но отмечай это
- tips: 3-5 практичных советов
- Температуры в градусах Цельсия`,
dest,
brief.StartDate,
brief.EndDate,
currentDate,
dailyForecastNote,
contextBuilder.String(),
time.Now().Format("2006"),
)
llmCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
llmCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
maxTokens := 3000
if tripDays > 5 {
maxTokens = 4000
}
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
Options: llm.StreamOptions{MaxTokens: 2000, Temperature: 0.2},
Options: llm.StreamOptions{MaxTokens: maxTokens, Temperature: 0.3},
})
if err != nil {
log.Printf("[travel-context] LLM extraction failed: %v", err)
@@ -233,9 +287,31 @@ func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *Tri
travelCtx.Safety.EmergencyNo = "112"
}
log.Printf("[travel-context] extracted context: weather=%s, safety=%s, restrictions=%d, tips=%d",
travelCtx.Weather.Conditions, travelCtx.Safety.Level,
len(travelCtx.Restrictions), len(travelCtx.Tips))
log.Printf("[travel-context] extracted context: weather=%s (%d daily), safety=%s, restrictions=%d, tips=%d",
travelCtx.Weather.Conditions, len(travelCtx.Weather.DailyForecast),
travelCtx.Safety.Level, len(travelCtx.Restrictions), len(travelCtx.Tips))
return &travelCtx
}
func computeTripDays(startDate, endDate string) int {
if startDate == "" || endDate == "" {
return 0
}
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
return 0
}
end, err := time.Parse("2006-01-02", endDate)
if err != nil {
return 0
}
days := int(end.Sub(start).Hours()/24) + 1
if days < 1 {
return 1
}
if days > 30 {
return 30
}
return days
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -40,6 +41,24 @@ func NewTravelDataClient(baseURL string) *TravelDataClient {
}
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
// http.Request.Clone does NOT recreate the Body. If we retry a request with a Body,
// we must be able to recreate it; otherwise retries will send an empty body and may
// fail with ContentLength/body length mismatch.
var bodyCopy []byte
if req.Body != nil && req.GetBody == nil {
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read request body for retry: %w", err)
}
_ = req.Body.Close()
bodyCopy = b
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyCopy)), nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.ContentLength = int64(len(bodyCopy))
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
@@ -51,7 +70,18 @@ func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (
}
}
resp, err := c.httpClient.Do(req.Clone(ctx))
reqAttempt := req.Clone(ctx)
if req.GetBody != nil {
rc, err := req.GetBody()
if err != nil {
lastErr = err
continue
}
reqAttempt.Body = rc
reqAttempt.ContentLength = req.ContentLength
}
resp, err := c.httpClient.Do(reqAttempt)
if err != nil {
lastErr = err
continue
@@ -306,13 +336,16 @@ func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, c
// PlaceResult represents a place from 2GIS Places API.
type PlaceResult struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Type string `json:"type"`
Purpose string `json:"purpose"`
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Type string `json:"type"`
Purpose string `json:"purpose"`
Rating float64 `json:"rating"`
ReviewCount int `json:"reviewCount"`
Schedule map[string]string `json:"schedule,omitempty"`
}
func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {

View File

@@ -39,12 +39,16 @@ func CollectEventsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, br
events := extractEventsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
events = geocodeEvents(ctx, cfg, events)
events = geocodeEvents(ctx, cfg, brief, events)
events = deduplicateEvents(events)
events = filterFreshEvents(events, brief.StartDate)
// Hard filter: drop events that ended up in another city/country due to ambiguous geocoding.
destGeo := geocodeDestinations(ctx, cfg, brief)
events = filterEventsNearDestinations(events, destGeo, 250)
if len(events) > 15 {
events = events[:15]
}
@@ -425,28 +429,74 @@ func tryPartialEventParse(jsonStr string) []EventCard {
return events
}
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, events []EventCard) []EventCard {
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, events []EventCard) []EventCard {
destSuffix := strings.Join(brief.Destinations, ", ")
for i := range events {
if events[i].Address == "" || (events[i].Lat != 0 && events[i].Lng != 0) {
continue
}
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
geo, err := cfg.TravelData.Geocode(geoCtx, events[i].Address)
cancel()
queries := []string{events[i].Address}
if destSuffix != "" && !strings.Contains(strings.ToLower(events[i].Address), strings.ToLower(destSuffix)) {
queries = append(queries, fmt.Sprintf("%s, %s", events[i].Address, destSuffix))
}
queries = append(queries, fmt.Sprintf("%s, %s", events[i].Title, destSuffix))
if err != nil {
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, err)
continue
var lastErr error
for _, q := range queries {
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
geo, err := cfg.TravelData.Geocode(geoCtx, q)
cancel()
if err != nil {
lastErr = err
continue
}
events[i].Lat = geo.Lat
events[i].Lng = geo.Lng
break
}
events[i].Lat = geo.Lat
events[i].Lng = geo.Lng
if events[i].Lat == 0 && events[i].Lng == 0 {
if lastErr != nil {
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, lastErr)
} else {
log.Printf("[travel-events] geocode failed for '%s'", events[i].Address)
}
continue
}
}
return events
}
func filterEventsNearDestinations(events []EventCard, destinations []destGeoEntry, maxKm float64) []EventCard {
if len(destinations) == 0 {
return events
}
filtered := make([]EventCard, 0, len(events))
for _, e := range events {
if e.Lat == 0 && e.Lng == 0 {
continue
}
minD := 1e18
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(e.Lat, e.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, e)
} else {
log.Printf("[travel-events] dropped far event '%s' (%.0fkm from destinations)", e.Title, minD)
}
}
return filtered
}
func deduplicateEvents(events []EventCard) []EventCard {
seen := make(map[string]bool)
var unique []EventCard

View File

@@ -36,6 +36,8 @@ func CollectHotelsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, br
hotels = deduplicateHotels(hotels)
hotels = filterHotelsNearDestinations(hotels, destinations, 250)
if len(hotels) > 10 {
hotels = hotels[:10]
}
@@ -400,6 +402,34 @@ func geocodeHotels(ctx context.Context, cfg TravelOrchestratorConfig, hotels []H
return hotels
}
func filterHotelsNearDestinations(hotels []HotelCard, destinations []destGeoEntry, maxKm float64) []HotelCard {
if len(destinations) == 0 {
return hotels
}
filtered := make([]HotelCard, 0, len(hotels))
for _, h := range hotels {
if h.Lat == 0 && h.Lng == 0 {
continue
}
minD := 1e18
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(h.Lat, h.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, h)
} else {
log.Printf("[travel-hotels] dropped far hotel '%s' (%.0fkm from destinations)", h.Name, minD)
}
}
return filtered
}
func deduplicateHotels(hotels []HotelCard) []HotelCard {
seen := make(map[string]bool)
var unique []HotelCard

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"regexp"
"strings"
"sync"
"time"
"github.com/gooseek/backend/internal/llm"
@@ -22,6 +24,7 @@ type TravelOrchestratorConfig struct {
LLM llm.Client
SearchClient *search.SearXNGClient
TravelData *TravelDataClient
PhotoCache *PhotoCacheService
Crawl4AIURL string
Locale string
TravelPayoutsToken string
@@ -35,6 +38,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
LLM: input.Config.LLM,
SearchClient: input.Config.SearchClient,
TravelData: NewTravelDataClient(input.Config.TravelSvcURL),
PhotoCache: input.Config.PhotoCache,
Crawl4AIURL: input.Config.Crawl4AIURL,
Locale: input.Config.Locale,
TravelPayoutsToken: input.Config.TravelPayoutsToken,
@@ -66,6 +70,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
}
brief.ApplyDefaults()
enforceDefaultSingleDay(brief, input.FollowUp)
// Geocode origin if we have a name but no coordinates
if brief.Origin != "" && brief.OriginLat == 0 && brief.OriginLng == 0 {
@@ -79,6 +84,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
// --- Phase 2: Geocode destinations ---
destGeo := geocodeDestinations(ctx, travelCfg, brief)
destGeo = enforceOneDayFeasibility(ctx, &travelCfg, brief, destGeo)
sess.UpdateBlock(researchBlockID, []session.Patch{{
Op: "replace",
@@ -103,6 +109,62 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
var draftMu sync.Mutex
var emitMu sync.Mutex
emitCandidatesWidget := func(kind string) {
emitMu.Lock()
defer emitMu.Unlock()
draftMu.Lock()
defer draftMu.Unlock()
switch kind {
case "context":
if draft.Context == nil {
return
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelContext), map[string]interface{}{
"weather": draft.Context.Weather,
"safety": draft.Context.Safety,
"restrictions": draft.Context.Restrictions,
"tips": draft.Context.Tips,
"bestTimeInfo": draft.Context.BestTimeInfo,
}))
case "events":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
"count": len(draft.Candidates.Events),
}))
case "pois":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
"count": len(draft.Candidates.POIs),
}))
case "hotels":
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
"count": len(draft.Candidates.Hotels),
}))
case "transport":
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
default:
return
}
}
collectCtx, collectCancel := context.WithTimeout(ctx, 90*time.Second)
defer collectCancel()
@@ -116,7 +178,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] events collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Events = events
draftMu.Unlock()
emitCandidatesWidget("events")
return nil
})
@@ -127,7 +192,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] POI collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.POIs = pois
draftMu.Unlock()
emitCandidatesWidget("pois")
return nil
})
@@ -138,7 +206,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] hotels collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Hotels = hotels
draftMu.Unlock()
emitCandidatesWidget("hotels")
return nil
})
@@ -149,7 +220,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] transport collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Candidates.Transport = transport
draftMu.Unlock()
emitCandidatesWidget("transport")
return nil
})
@@ -160,7 +234,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
log.Printf("[travel] context collection error: %v", err)
return nil
}
draftMu.Lock()
draft.Context = travelCtx
draftMu.Unlock()
emitCandidatesWidget("context")
return nil
})
@@ -197,6 +274,55 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
return nil
}
func userExplicitlyProvidedDateRange(text string) bool {
t := strings.ToLower(text)
isoDate := regexp.MustCompile(`\b20\d{2}-\d{2}-\d{2}\b`)
if len(isoDate.FindAllString(t, -1)) >= 2 {
return true
}
loose := regexp.MustCompile(`\b\d{1,2}[./-]\d{1,2}([./-]\d{2,4})?\b`)
if strings.Contains(t, "с ") && strings.Contains(t, " по ") && len(loose.FindAllString(t, -1)) >= 2 {
return true
}
return false
}
func enforceDefaultSingleDay(brief *TripBrief, userText string) {
// Product rule: default to ONE day unless user explicitly provided start+end dates.
if !userExplicitlyProvidedDateRange(userText) {
brief.EndDate = brief.StartDate
}
}
func enforceOneDayFeasibility(ctx context.Context, cfg *TravelOrchestratorConfig, brief *TripBrief, destGeo []destGeoEntry) []destGeoEntry {
// If it's a one-day request and origin+destination are far apart,
// plan locally around origin (user is already there).
if brief.StartDate == "" || brief.EndDate == "" || brief.StartDate != brief.EndDate {
return destGeo
}
if brief.Origin == "" {
return destGeo
}
if brief.OriginLat == 0 && brief.OriginLng == 0 {
return destGeo
}
if len(destGeo) == 0 || (destGeo[0].Lat == 0 && destGeo[0].Lng == 0) {
return destGeo
}
d := distanceKm(brief.OriginLat, brief.OriginLng, destGeo[0].Lat, destGeo[0].Lng)
if d <= 250 {
return destGeo
}
log.Printf("[travel] one-day request but destination is far (%.0fkm) — switching destination to origin %q", d, brief.Origin)
brief.Destinations = []string{brief.Origin}
return geocodeDestinations(ctx, *cfg, brief)
}
// --- Phase 1: Planner Agent ---
func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input OrchestratorInput) (*TripBrief, error) {
@@ -219,9 +345,13 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
}
Правила:
- Если пользователь говорит "сегодня" — startDate = текущая дата (` + time.Now().Format("2006-01-02") + `)
- Для однодневных поездок endDate = startDate
- Если дата не указана, оставь пустую строку ""
- Сегодняшняя дата: ` + time.Now().Format("2006-01-02") + `
- Если пользователь говорит "сегодня" — startDate = сегодняшняя дата
- Если пользователь говорит "завтра" — startDate = завтрашняя дата (` + time.Now().AddDate(0, 0, 1).Format("2006-01-02") + `)
- Если пользователь говорит "послезавтра" — startDate = послезавтрашняя дата (` + time.Now().AddDate(0, 0, 2).Format("2006-01-02") + `)
- ВАЖНО: По умолчанию планируем ОДИН день. Если пользователь не указал конечную дату явно — endDate оставь пустой строкой ""
- endDate заполняй ТОЛЬКО если пользователь явно указал диапазон дат (дата начала И дата конца)
- Если дата не указана вообще, оставь пустую строку ""
- Если бюджет не указан, поставь 0
- Если количество путешественников не указано, поставь 0
- ВАЖНО: Если в сообщении есть координаты "Моё текущее местоположение: lat, lng", используй их:
@@ -257,9 +387,18 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
var brief TripBrief
if err := json.Unmarshal([]byte(jsonMatch), &brief); err != nil {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
repaired := repairJSON(jsonMatch)
if repaired != "" {
if err2 := json.Unmarshal([]byte(repaired), &brief); err2 != nil {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
}
} else {
return &TripBrief{
Destinations: extractDestinationsFromText(input.FollowUp),
}, nil
}
}
if len(brief.Destinations) == 0 {
@@ -362,19 +501,21 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
}
type poiCompact struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Duration int `json:"duration"`
Price float64 `json:"price"`
Address string `json:"address"`
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Duration int `json:"duration"`
Price float64 `json:"price"`
Address string `json:"address"`
Schedule map[string]string `json:"schedule,omitempty"`
}
type eventCompact struct {
ID string `json:"id"`
Title string `json:"title"`
DateStart string `json:"dateStart"`
DateEnd string `json:"dateEnd,omitempty"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Price float64 `json:"price"`
@@ -398,6 +539,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
ID: p.ID, Name: p.Name, Category: p.Category,
Lat: p.Lat, Lng: p.Lng, Duration: dur,
Price: p.Price, Address: p.Address,
Schedule: p.Schedule,
})
}
@@ -405,6 +547,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
for _, e := range draft.Candidates.Events {
compactEvents = append(compactEvents, eventCompact{
ID: e.ID, Title: e.Title, DateStart: e.DateStart,
DateEnd: e.DateEnd,
Lat: e.Lat, Lng: e.Lng, Price: e.Price, Address: e.Address,
})
}
@@ -428,10 +571,26 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
"hotels": compactHotels,
}
if draft.Context != nil {
weatherCtx := map[string]interface{}{
"summary": draft.Context.Weather.Summary,
"tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax),
"conditions": draft.Context.Weather.Conditions,
}
if len(draft.Context.Weather.DailyForecast) > 0 {
dailyWeather := make([]map[string]interface{}, 0, len(draft.Context.Weather.DailyForecast))
for _, d := range draft.Context.Weather.DailyForecast {
dailyWeather = append(dailyWeather, map[string]interface{}{
"date": d.Date,
"tempMin": d.TempMin,
"tempMax": d.TempMax,
"conditions": d.Conditions,
"rainChance": d.RainChance,
})
}
weatherCtx["dailyForecast"] = dailyWeather
}
candidateData["context"] = map[string]interface{}{
"weather": draft.Context.Weather.Summary,
"tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax),
"conditions": draft.Context.Weather.Conditions,
"weather": weatherCtx,
"safetyLevel": draft.Context.Safety.Level,
"restrictions": draft.Context.Restrictions,
}
@@ -442,6 +601,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
Данные (с координатами для расчёта расстояний): %s
ВАЖНО: Если startDate == endDate — это ОДНОДНЕВНЫЙ план. Верни РОВНО 1 день в массиве "days" и поставь date=startDate.
КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ:
1. Используй координаты (lat, lng) для оценки расстояний между точками.
2. Средняя скорость передвижения по городу: 15-20 км/ч (пробки, пешком, общественный транспорт).
@@ -453,6 +614,12 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
8. Максимум 4-5 основных активностей в день (не считая еду и переезды).
9. День начинается в 09:00, заканчивается в 21:00. С детьми — до 19:00.
ПРАВИЛА ПОГОДЫ (если есть dailyForecast в context):
1. В дождливые дни (conditions: "дождь"/"гроза") — ставь крытые активности: музеи, торговые центры, рестораны, театры.
2. В солнечные дни — парки, смотровые площадки, прогулки, набережные.
3. В холодные дни (tempMax < 5°C) — больше крытых мест, меньше прогулок.
4. Если есть tip для дня — учитывай его при планировании.
ПРАВИЛА ЦЕН:
1. cost — цена НА ОДНОГО человека за эту активность.
2. Для бесплатных мест (парки, площади, улицы) — cost = 0.
@@ -488,6 +655,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
`+"```"+`
Дополнительные правила:
- Для refType="poi"|"event"|"hotel" ЗАПРЕЩЕНО выдумывать места. Используй ТОЛЬКО объекты из данных и ставь их "refId" из списка.
- Если подходящего POI/события/отеля в данных нет — используй refType="custom" (или "food" для еды) и ставь lat/lng = 0.
- Между точками ОБЯЗАТЕЛЬНО вставляй элемент "transfer" с refType="transfer" если расстояние > 1 км
- В note для transfer указывай расстояние и примерное время
- Начинай день с отеля/завтрака
@@ -506,14 +675,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
summaryText := extractTextBeforeJSON(response)
jsonMatch := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```").FindStringSubmatch(response)
var jsonStr string
if len(jsonMatch) > 1 {
jsonStr = strings.TrimSpace(jsonMatch[1])
} else {
jsonStr = regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response)
}
jsonStr := extractJSONFromResponse(response)
if jsonStr == "" {
return nil, summaryText, nil
}
@@ -522,15 +684,119 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
Days []ItineraryDay `json:"days"`
}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
log.Printf("[travel] itinerary JSON parse error: %v", err)
return nil, summaryText, nil
repaired := repairJSON(jsonStr)
if repaired != "" {
if err2 := json.Unmarshal([]byte(repaired), &result); err2 != nil {
log.Printf("[travel] itinerary JSON parse error (after repair): %v", err2)
return nil, summaryText, nil
}
} else {
log.Printf("[travel] itinerary JSON parse error: %v", err)
return nil, summaryText, nil
}
}
result.Days = validateItineraryTimes(result.Days)
result.Days = postValidateItinerary(result.Days, draft)
if draft.Brief != nil && draft.Brief.StartDate != "" && draft.Brief.EndDate == draft.Brief.StartDate && len(result.Days) > 1 {
// Defensive clamp: for one-day plans keep only the first day.
result.Days = result.Days[:1]
result.Days[0].Date = draft.Brief.StartDate
}
return result.Days, summaryText, nil
}
func postValidateItinerary(days []ItineraryDay, draft *TripDraft) []ItineraryDay {
poiByID := make(map[string]*POICard)
for i := range draft.Candidates.POIs {
poiByID[draft.Candidates.POIs[i].ID] = &draft.Candidates.POIs[i]
}
eventByID := make(map[string]*EventCard)
for i := range draft.Candidates.Events {
eventByID[draft.Candidates.Events[i].ID] = &draft.Candidates.Events[i]
}
hotelByID := make(map[string]*HotelCard)
for i := range draft.Candidates.Hotels {
hotelByID[draft.Candidates.Hotels[i].ID] = &draft.Candidates.Hotels[i]
}
// Build a centroid of "known-good" coordinates to detect out-of-area hallucinations.
var sumLat, sumLng float64
var cnt float64
addPoint := func(lat, lng float64) {
if lat == 0 && lng == 0 {
return
}
sumLat += lat
sumLng += lng
cnt++
}
for _, p := range draft.Candidates.POIs {
addPoint(p.Lat, p.Lng)
}
for _, e := range draft.Candidates.Events {
addPoint(e.Lat, e.Lng)
}
for _, h := range draft.Candidates.Hotels {
addPoint(h.Lat, h.Lng)
}
centLat, centLng := 0.0, 0.0
if cnt > 0 {
centLat = sumLat / cnt
centLng = sumLng / cnt
}
for d := range days {
for i := range days[d].Items {
item := &days[d].Items[i]
// If refId exists, always trust coordinates from candidates (even if LLM provided something else).
if item.RefID != "" {
if poi, ok := poiByID[item.RefID]; ok {
item.Lat, item.Lng = poi.Lat, poi.Lng
} else if ev, ok := eventByID[item.RefID]; ok {
item.Lat, item.Lng = ev.Lat, ev.Lng
} else if h, ok := hotelByID[item.RefID]; ok {
item.Lat, item.Lng = h.Lat, h.Lng
} else if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" {
// Unknown refId for these types → convert to custom to avoid cross-city junk.
item.RefType = "custom"
item.RefID = ""
item.Lat = 0
item.Lng = 0
if item.Note == "" {
item.Note = "Уточнить место: не найдено среди вариантов для города"
}
}
}
// Clamp out-of-area coordinates (e.g., another country) if we have a centroid.
if centLat != 0 || centLng != 0 {
if item.Lat != 0 || item.Lng != 0 {
if distanceKm(item.Lat, item.Lng, centLat, centLng) > 250 {
item.Lat = 0
item.Lng = 0
if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" {
item.RefType = "custom"
item.RefID = ""
}
if item.Note == "" {
item.Note = "Уточнить место: координаты вне города/маршрута"
}
}
}
}
if item.Currency == "" {
item.Currency = draft.Brief.Currency
}
}
}
return days
}
func validateItineraryTimes(days []ItineraryDay) []ItineraryDay {
for d := range days {
items := days[d].Items
@@ -577,6 +843,58 @@ func formatMinutesTime(minutes int) string {
return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
}
func extractJSONFromResponse(response string) string {
codeBlockRe := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
if m := codeBlockRe.FindStringSubmatch(response); len(m) > 1 {
return strings.TrimSpace(m[1])
}
if idx := strings.Index(response, `"days"`); idx >= 0 {
braceStart := strings.LastIndex(response[:idx], "{")
if braceStart >= 0 {
depth := 0
for i := braceStart; i < len(response); i++ {
switch response[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return response[braceStart : i+1]
}
}
}
}
}
return regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response)
}
func repairJSON(s string) string {
s = strings.TrimSpace(s)
s = regexp.MustCompile(`,\s*}`).ReplaceAllString(s, "}")
s = regexp.MustCompile(`,\s*]`).ReplaceAllString(s, "]")
openBraces := strings.Count(s, "{") - strings.Count(s, "}")
for openBraces > 0 {
s += "}"
openBraces--
}
openBrackets := strings.Count(s, "[") - strings.Count(s, "]")
for openBrackets > 0 {
s += "]"
openBrackets--
}
var test json.RawMessage
if json.Unmarshal([]byte(s), &test) == nil {
return s
}
return ""
}
func extractTextBeforeJSON(response string) string {
idx := strings.Index(response, "```")
if idx > 0 {
@@ -674,44 +992,39 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelMap), widgetParams))
}
// Events widget
if len(draft.Candidates.Events) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
}))
}
// Events widget (always emit — UI shows empty state)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{
"events": draft.Candidates.Events,
"count": len(draft.Candidates.Events),
}))
// POI widget
if len(draft.Candidates.POIs) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
}))
}
// POI widget (always emit)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
"pois": draft.Candidates.POIs,
"count": len(draft.Candidates.POIs),
}))
// Hotels widget
if len(draft.Candidates.Hotels) > 0 {
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
}))
}
// Hotels widget (always emit)
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
"hotels": draft.Candidates.Hotels,
"count": len(draft.Candidates.Hotels),
}))
// Transport widget
if len(draft.Candidates.Transport) > 0 {
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
// Transport widget (always emit)
flights := make([]TransportOption, 0)
ground := make([]TransportOption, 0)
for _, t := range draft.Candidates.Transport {
if t.Mode == "flight" {
flights = append(flights, t)
} else {
ground = append(ground, t)
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{
"flights": flights,
"ground": ground,
"passengers": draft.Brief.Travelers,
}))
// Itinerary widget
if len(draft.Selected.Itinerary) > 0 {
@@ -723,6 +1036,9 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
if len(segments) > 0 {
itineraryParams["segments"] = segments
}
if draft.Context != nil && len(draft.Context.Weather.DailyForecast) > 0 {
itineraryParams["dailyForecast"] = draft.Context.Weather.DailyForecast
}
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelItinerary), itineraryParams))
}
@@ -735,15 +1051,6 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
"perPerson": budget.PerPerson,
}))
}
// Actions widget
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelActions), map[string]interface{}{
"actions": []map[string]interface{}{
{"id": "save_trip", "label": "Сохранить поездку", "kind": "save", "payload": map[string]interface{}{}},
{"id": "modify_route", "label": "Изменить маршрут", "kind": "modify", "payload": map[string]interface{}{}},
{"id": "add_more", "label": "Найти ещё варианты", "kind": "search", "payload": map[string]interface{}{}},
},
}))
}
func buildMapPoints(draft *TripDraft, destGeo []destGeoEntry) []MapPoint {
@@ -1020,90 +1327,135 @@ func buildRoadRoute(ctx context.Context, cfg *TravelOrchestratorConfig, points [
return nil, nil
}
routeCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
routeCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
log.Printf("[travel] building road route segment-by-segment for %d points", len(points))
segments := buildSegmentCosts(points)
// 2GIS supports up to 10 waypoints per request; batch accordingly
const maxWaypoints = 10
log.Printf("[travel] building batched multi-point route for %d points (batch size %d)", len(points), maxWaypoints)
var allCoords [][2]float64
var allSteps []RouteStepResult
var totalDistance, totalDuration float64
segments := make([]routeSegmentWithCosts, 0, len(points)-1)
batchOK := true
for i := 0; i < len(points)-1; i++ {
if i > 0 {
for batchStart := 0; batchStart < len(points)-1; batchStart += maxWaypoints - 1 {
batchEnd := batchStart + maxWaypoints
if batchEnd > len(points) {
batchEnd = len(points)
}
batch := points[batchStart:batchEnd]
if len(batch) < 2 {
break
}
if batchStart > 0 {
select {
case <-routeCtx.Done():
batchOK = false
case <-time.After(1500 * time.Millisecond):
}
if !batchOK {
break
case <-time.After(300 * time.Millisecond):
}
}
pair := []MapPoint{points[i], points[i+1]}
var segDir *RouteDirectionResult
var batchRoute *RouteDirectionResult
var err error
for attempt := 0; attempt < 3; attempt++ {
segDir, err = cfg.TravelData.GetRoute(routeCtx, pair, "driving")
if err == nil || !strings.Contains(err.Error(), "429") {
batchRoute, err = cfg.TravelData.GetRoute(routeCtx, batch, "driving")
if err == nil {
break
}
log.Printf("[travel] segment %d->%d rate limited, retry %d", i, i+1, attempt+1)
if !strings.Contains(err.Error(), "429") {
break
}
log.Printf("[travel] batch %d-%d rate limited, retry %d", batchStart, batchEnd-1, attempt+1)
select {
case <-routeCtx.Done():
batchOK = false
case <-time.After(time.Duration(2+attempt*2) * time.Second):
}
if !batchOK {
break
case <-time.After(time.Duration(1+attempt) * time.Second):
}
}
var distanceM, durationS float64
if err != nil {
log.Printf("[travel] segment %d->%d routing failed: %v", i, i+1, err)
} else if segDir != nil {
distanceM = segDir.Distance
durationS = segDir.Duration
totalDistance += distanceM
totalDuration += durationS
if len(segDir.Geometry.Coordinates) > 0 {
if len(allCoords) > 0 && len(segDir.Geometry.Coordinates) > 0 {
allCoords = append(allCoords, segDir.Geometry.Coordinates[1:]...)
} else {
allCoords = append(allCoords, segDir.Geometry.Coordinates...)
}
}
allSteps = append(allSteps, segDir.Steps...)
log.Printf("[travel] batch %d-%d routing failed: %v", batchStart, batchEnd-1, err)
batchOK = false
break
}
if batchRoute == nil || len(batchRoute.Geometry.Coordinates) < 2 {
log.Printf("[travel] batch %d-%d returned empty geometry", batchStart, batchEnd-1)
batchOK = false
break
}
totalDistance += batchRoute.Distance
totalDuration += batchRoute.Duration
if len(allCoords) > 0 {
allCoords = append(allCoords, batchRoute.Geometry.Coordinates[1:]...)
} else {
allCoords = append(allCoords, batchRoute.Geometry.Coordinates...)
}
allSteps = append(allSteps, batchRoute.Steps...)
log.Printf("[travel] batch %d-%d OK: +%.0fm, +%d coords", batchStart, batchEnd-1, batchRoute.Distance, len(batchRoute.Geometry.Coordinates))
}
if batchOK && len(allCoords) > 1 {
fullRoute := &RouteDirectionResult{
Geometry: RouteGeometryResult{
Coordinates: allCoords,
Type: "LineString",
},
Distance: totalDistance,
Duration: totalDuration,
Steps: allSteps,
}
log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments))
return fullRoute, segments
}
log.Printf("[travel] batched routing failed, no road coordinates collected")
return nil, segments
}
func buildSegmentCosts(points []MapPoint) []routeSegmentWithCosts {
segments := make([]routeSegmentWithCosts, 0, len(points)-1)
for i := 0; i < len(points)-1; i++ {
distKm := haversineDistance(points[i].Lat, points[i].Lng, points[i+1].Lat, points[i+1].Lng)
distM := distKm * 1000
durationS := distKm / 40.0 * 3600 // ~40 km/h average
seg := routeSegmentWithCosts{
From: points[i].Label,
To: points[i+1].Label,
Distance: distanceM,
Distance: distM,
Duration: durationS,
}
if distanceM > 0 {
seg.TransportOptions = calculateTransportCosts(distanceM, durationS)
if distM > 0 {
seg.TransportOptions = calculateTransportCosts(distM, durationS)
}
segments = append(segments, seg)
}
return segments
}
if len(allCoords) == 0 {
log.Printf("[travel] no road coordinates collected")
return nil, segments
}
func haversineDistance(lat1, lng1, lat2, lng2 float64) float64 {
const R = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180
dLng := (lng2 - lng1) * math.Pi / 180
lat1Rad := lat1 * math.Pi / 180
lat2Rad := lat2 * math.Pi / 180
fullRoute := &RouteDirectionResult{
Geometry: RouteGeometryResult{
Coordinates: allCoords,
Type: "LineString",
},
Distance: totalDistance,
Duration: totalDuration,
Steps: allSteps,
}
log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments))
return fullRoute, segments
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Asin(math.Sqrt(a))
return R * c
}
func calculateTransportCosts(distanceMeters float64, durationSeconds float64) []transportCostOption {

View File

@@ -0,0 +1,194 @@
package agent
import (
"context"
"crypto/sha256"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gooseek/backend/pkg/storage"
)
const (
photoCachePrefix = "poi-photos"
maxPhotoSize = 5 * 1024 * 1024 // 5MB
photoDownloadTimeout = 8 * time.Second
)
type PhotoCacheService struct {
storage *storage.MinioStorage
client *http.Client
mu sync.RWMutex
memCache map[string]string // sourceURL -> publicURL (in-memory for current session)
}
func NewPhotoCacheService(s *storage.MinioStorage) *PhotoCacheService {
return &PhotoCacheService{
storage: s,
client: &http.Client{
Timeout: photoDownloadTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
},
},
memCache: make(map[string]string, 128),
}
}
func (pc *PhotoCacheService) CachePhoto(ctx context.Context, citySlug, sourceURL string) (string, error) {
pc.mu.RLock()
if cached, ok := pc.memCache[sourceURL]; ok {
pc.mu.RUnlock()
return cached, nil
}
pc.mu.RUnlock()
key := pc.buildKey(citySlug, sourceURL)
exists, err := pc.storage.ObjectExists(ctx, key)
if err == nil && exists {
publicURL := pc.storage.GetPublicURL(key)
if publicURL != "" {
pc.mu.Lock()
pc.memCache[sourceURL] = publicURL
pc.mu.Unlock()
return publicURL, nil
}
}
body, contentType, err := pc.downloadImage(ctx, sourceURL)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer body.Close()
limitedReader := io.LimitReader(body, maxPhotoSize)
result, err := pc.storage.UploadWithKey(ctx, key, limitedReader, -1, contentType)
if err != nil {
return "", fmt.Errorf("upload to minio failed: %w", err)
}
publicURL := pc.storage.GetPublicURL(result.Key)
if publicURL == "" {
return "", fmt.Errorf("no public URL configured for storage")
}
pc.mu.Lock()
pc.memCache[sourceURL] = publicURL
pc.mu.Unlock()
return publicURL, nil
}
func (pc *PhotoCacheService) CachePhotoBatch(ctx context.Context, citySlug string, sourceURLs []string) []string {
results := make([]string, len(sourceURLs))
var wg sync.WaitGroup
for i, url := range sourceURLs {
wg.Add(1)
go func(idx int, srcURL string) {
defer wg.Done()
cacheCtx, cancel := context.WithTimeout(ctx, photoDownloadTimeout+2*time.Second)
defer cancel()
cached, err := pc.CachePhoto(cacheCtx, citySlug, srcURL)
if err != nil {
log.Printf("[photo-cache] failed to cache %s: %v", truncateURL(srcURL), err)
results[idx] = srcURL
return
}
results[idx] = cached
}(i, url)
}
wg.Wait()
return results
}
func (pc *PhotoCacheService) buildKey(citySlug, sourceURL string) string {
hash := sha256.Sum256([]byte(sourceURL))
hashStr := fmt.Sprintf("%x", hash[:12])
ext := ".jpg"
lower := strings.ToLower(sourceURL)
switch {
case strings.Contains(lower, ".png"):
ext = ".png"
case strings.Contains(lower, ".webp"):
ext = ".webp"
case strings.Contains(lower, ".gif"):
ext = ".gif"
}
slug := sanitizeSlug(citySlug)
return fmt.Sprintf("%s/%s/%s%s", photoCachePrefix, slug, hashStr, ext)
}
func (pc *PhotoCacheService) downloadImage(ctx context.Context, url string) (io.ReadCloser, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; GooSeek/1.0)")
req.Header.Set("Accept", "image/*")
resp, err := pc.client.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/jpeg"
}
if !strings.HasPrefix(contentType, "image/") {
resp.Body.Close()
return nil, "", fmt.Errorf("not an image: %s", contentType)
}
return resp.Body, contentType, nil
}
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
if (r >= 0x0400 && r <= 0x04FF) || r == '_' {
return r
}
if r == ' ' {
return '-'
}
return -1
}, s)
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return strings.Trim(s, "-")
}
func truncateURL(u string) string {
if len(u) > 80 {
return u[:80] + "..."
}
return u
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"regexp"
"strings"
"sync"
@@ -103,7 +104,11 @@ func CollectPOIsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brie
}
// Phase 4: Fallback geocoding for POIs without coordinates
allPOIs = geocodePOIs(ctx, cfg, allPOIs)
allPOIs = geocodePOIs(ctx, cfg, brief, allPOIs)
// Hard filter: drop POIs that are far away from any destination center.
// This prevents ambiguous geocoding from pulling in other cities/countries.
allPOIs = filterPOIsNearDestinations(allPOIs, destinations, 250)
allPOIs = deduplicatePOIs(allPOIs)
@@ -453,6 +458,14 @@ func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *T
}
if len(photos) > 0 {
if cfg.PhotoCache != nil {
citySlug := dest
if citySlug == "" {
citySlug = "unknown"
}
photos = cfg.PhotoCache.CachePhotoBatch(ctx, citySlug, photos)
}
mu.Lock()
pois[idx].Photos = photos
mu.Unlock()
@@ -463,12 +476,18 @@ func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *T
wg.Wait()
photosFound := 0
cachedCount := 0
for _, p := range pois {
if len(p.Photos) > 0 {
photosFound++
for _, ph := range p.Photos {
if strings.Contains(ph, "storage.gooseek") || strings.Contains(ph, "minio") {
cachedCount++
}
}
}
}
log.Printf("[travel-poi] enriched %d/%d POIs with photos", photosFound, len(pois))
log.Printf("[travel-poi] enriched %d/%d POIs with photos (%d cached in MinIO)", photosFound, len(pois), cachedCount)
return pois
}
@@ -636,19 +655,27 @@ func extractPOIsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBr
return pois
}
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICard) []POICard {
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
destSuffix := strings.Join(brief.Destinations, ", ")
for i := range pois {
if pois[i].Lat != 0 && pois[i].Lng != 0 {
continue
}
// Try geocoding by address first, then by name + city
// Try geocoding by address first, then by name+destination.
queries := []string{}
if pois[i].Address != "" {
queries = append(queries, pois[i].Address)
if destSuffix != "" && !strings.Contains(strings.ToLower(pois[i].Address), strings.ToLower(destSuffix)) {
queries = append(queries, fmt.Sprintf("%s, %s", pois[i].Address, destSuffix))
}
}
if pois[i].Name != "" {
queries = append(queries, pois[i].Name)
if destSuffix != "" {
queries = append(queries, fmt.Sprintf("%s, %s", pois[i].Name, destSuffix))
} else {
queries = append(queries, pois[i].Name)
}
}
for _, query := range queries {
@@ -674,6 +701,46 @@ func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICa
return pois
}
func distanceKm(lat1, lng1, lat2, lng2 float64) float64 {
const earthRadiusKm = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
lat1r := toRad(lat1)
lat2r := toRad(lat2)
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1r)*math.Cos(lat2r)*math.Sin(dLng/2)*math.Sin(dLng/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKm * c
}
func filterPOIsNearDestinations(pois []POICard, destinations []destGeoEntry, maxKm float64) []POICard {
if len(destinations) == 0 {
return pois
}
filtered := make([]POICard, 0, len(pois))
for _, p := range pois {
if p.Lat == 0 && p.Lng == 0 {
continue
}
minD := math.MaxFloat64
for _, d := range destinations {
if d.Lat == 0 && d.Lng == 0 {
continue
}
dd := distanceKm(p.Lat, p.Lng, d.Lat, d.Lng)
if dd < minD {
minD = dd
}
}
if minD <= maxKm {
filtered = append(filtered, p)
} else {
log.Printf("[travel-poi] dropped far POI '%s' (%.0fkm from destinations)", p.Name, minD)
}
}
return filtered
}
func deduplicatePOIs(pois []POICard) []POICard {
seen := make(map[string]bool)
var unique []POICard

View File

@@ -30,12 +30,7 @@ func (b *TripBrief) ApplyDefaults() {
b.StartDate = time.Now().Format("2006-01-02")
}
if b.EndDate == "" {
start, err := time.Parse("2006-01-02", b.StartDate)
if err == nil {
b.EndDate = start.AddDate(0, 0, 3).Format("2006-01-02")
} else {
b.EndDate = b.StartDate
}
b.EndDate = b.StartDate
}
if b.Travelers == 0 {
b.Travelers = 2