feat: CI/CD pipeline + Learning/Medicine/Travel services
- 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:
836
backend/internal/agent/learning_orchestrator.go
Normal file
836
backend/internal/agent/learning_orchestrator.go
Normal 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
|
||||
}
|
||||
@@ -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: ")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
194
backend/internal/agent/travel_photo_cache.go
Normal file
194
backend/internal/agent/travel_photo_cache.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
642
backend/internal/db/learning_repo.go
Normal file
642
backend/internal/db/learning_repo.go
Normal file
@@ -0,0 +1,642 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LearningRepository struct {
|
||||
db *PostgresDB
|
||||
}
|
||||
|
||||
func NewLearningRepository(db *PostgresDB) *LearningRepository {
|
||||
return &LearningRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LearningRepository) RunMigrations(ctx context.Context) error {
|
||||
migrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS learning_user_profiles (
|
||||
user_id UUID PRIMARY KEY,
|
||||
display_name VARCHAR(255),
|
||||
profile JSONB NOT NULL DEFAULT '{}',
|
||||
resume_file_id UUID,
|
||||
resume_extracted_text TEXT,
|
||||
onboarding_completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS learning_courses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
short_description TEXT,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
difficulty VARCHAR(50) NOT NULL DEFAULT 'beginner',
|
||||
duration_hours INT DEFAULT 0,
|
||||
base_outline JSONB NOT NULL DEFAULT '{}',
|
||||
landing JSONB NOT NULL DEFAULT '{}',
|
||||
cover_image TEXT,
|
||||
fingerprint VARCHAR(128) UNIQUE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||
enrolled_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_courses_status ON learning_courses(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_courses_category ON learning_courses(category)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS learning_enrollments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
course_id UUID NOT NULL REFERENCES learning_courses(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
plan JSONB NOT NULL DEFAULT '{}',
|
||||
progress JSONB NOT NULL DEFAULT '{"completed_modules": [], "current_module": 0, "score": 0}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, course_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_enrollments_user ON learning_enrollments(user_id)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS learning_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
enrollment_id UUID NOT NULL REFERENCES learning_enrollments(id) ON DELETE CASCADE,
|
||||
module_index INT NOT NULL DEFAULT 0,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
task_type VARCHAR(50) NOT NULL DEFAULT 'code',
|
||||
instructions_md TEXT NOT NULL,
|
||||
rubric JSONB NOT NULL DEFAULT '{}',
|
||||
sandbox_template JSONB NOT NULL DEFAULT '{}',
|
||||
verification_cmd TEXT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_tasks_enrollment ON learning_tasks(enrollment_id)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS learning_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL REFERENCES learning_tasks(id) ON DELETE CASCADE,
|
||||
sandbox_session_id UUID,
|
||||
result JSONB NOT NULL DEFAULT '{}',
|
||||
score INT DEFAULT 0,
|
||||
max_score INT DEFAULT 100,
|
||||
feedback_md TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_submissions_task ON learning_submissions(task_id)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS learning_trend_candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
topic VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||
signals JSONB NOT NULL DEFAULT '{}',
|
||||
score FLOAT DEFAULT 0,
|
||||
fingerprint VARCHAR(128) UNIQUE,
|
||||
fail_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_failed_at TIMESTAMPTZ,
|
||||
picked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_trends_score ON learning_trend_candidates(score DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_learning_trends_fail ON learning_trend_candidates(fail_count, last_failed_at)`,
|
||||
|
||||
// Backward-compatible schema upgrades (older DBs)
|
||||
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS fail_count INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_error TEXT`,
|
||||
`ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_failed_at TIMESTAMPTZ`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS sandbox_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
task_id UUID REFERENCES learning_tasks(id) ON DELETE SET NULL,
|
||||
opensandbox_id VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'creating',
|
||||
last_active_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sandbox_sessions_user ON sandbox_sessions(user_id)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS sandbox_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sandbox_sessions(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sandbox_events_session ON sandbox_events(session_id)`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := r.db.db.ExecContext(ctx, m); err != nil {
|
||||
return fmt.Errorf("learning migration failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Course types ---
|
||||
|
||||
type LearningCourse struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
ShortDescription string `json:"shortDescription"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
DurationHours int `json:"durationHours"`
|
||||
BaseOutline json.RawMessage `json:"baseOutline"`
|
||||
Landing json.RawMessage `json:"landing"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Status string `json:"status"`
|
||||
EnrolledCount int `json:"enrolledCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type LearningUserProfile struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Profile json.RawMessage `json:"profile"`
|
||||
ResumeFileID *string `json:"resumeFileId,omitempty"`
|
||||
ResumeExtractedText string `json:"resumeExtractedText,omitempty"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type LearningEnrollment struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
CourseID string `json:"courseId"`
|
||||
Status string `json:"status"`
|
||||
Plan json.RawMessage `json:"plan"`
|
||||
Progress json.RawMessage `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Course *LearningCourse `json:"course,omitempty"`
|
||||
}
|
||||
|
||||
type LearningTask struct {
|
||||
ID string `json:"id"`
|
||||
EnrollmentID string `json:"enrollmentId"`
|
||||
ModuleIndex int `json:"moduleIndex"`
|
||||
Title string `json:"title"`
|
||||
TaskType string `json:"taskType"`
|
||||
InstructionsMD string `json:"instructionsMd"`
|
||||
Rubric json.RawMessage `json:"rubric"`
|
||||
SandboxTemplate json.RawMessage `json:"sandboxTemplate"`
|
||||
VerificationCmd string `json:"verificationCmd,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type LearningSubmission struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
SandboxSessionID *string `json:"sandboxSessionId,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Score int `json:"score"`
|
||||
MaxScore int `json:"maxScore"`
|
||||
FeedbackMD string `json:"feedbackMd,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LearningTrendCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
Category string `json:"category"`
|
||||
Signals json.RawMessage `json:"signals"`
|
||||
Score float64 `json:"score"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
FailCount int `json:"failCount,omitempty"`
|
||||
LastError *string `json:"lastError,omitempty"`
|
||||
LastFailedAt *time.Time `json:"lastFailedAt,omitempty"`
|
||||
PickedAt *time.Time `json:"pickedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SandboxSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
TaskID *string `json:"taskId,omitempty"`
|
||||
OpenSandboxID string `json:"opensandboxId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastActiveAt time.Time `json:"lastActiveAt"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// --- Courses ---
|
||||
|
||||
func (r *LearningRepository) ListCourses(ctx context.Context, category, difficulty, search string, limit, offset int) ([]*LearningCourse, int, error) {
|
||||
where := "status = 'published'"
|
||||
args := make([]interface{}, 0)
|
||||
argIdx := 1
|
||||
|
||||
if category != "" {
|
||||
where += fmt.Sprintf(" AND category = $%d", argIdx)
|
||||
args = append(args, category)
|
||||
argIdx++
|
||||
}
|
||||
if difficulty != "" {
|
||||
where += fmt.Sprintf(" AND difficulty = $%d", argIdx)
|
||||
args = append(args, difficulty)
|
||||
argIdx++
|
||||
}
|
||||
if search != "" {
|
||||
where += fmt.Sprintf(" AND (title ILIKE $%d OR short_description ILIKE $%d)", argIdx, argIdx)
|
||||
args = append(args, "%"+search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM learning_courses WHERE %s", where)
|
||||
var total int
|
||||
if err := r.db.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
|
||||
base_outline, landing, cover_image, status, enrolled_count, created_at, updated_at
|
||||
FROM learning_courses WHERE %s ORDER BY enrolled_count DESC, created_at DESC LIMIT $%d OFFSET $%d`,
|
||||
where, argIdx, argIdx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := r.db.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var courses []*LearningCourse
|
||||
for rows.Next() {
|
||||
c := &LearningCourse{}
|
||||
var tags []byte
|
||||
var coverImg sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
|
||||
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
|
||||
&c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if coverImg.Valid {
|
||||
c.CoverImage = coverImg.String
|
||||
}
|
||||
json.Unmarshal(tags, &c.Tags)
|
||||
courses = append(courses, c)
|
||||
}
|
||||
return courses, total, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetCourseBySlug(ctx context.Context, slug string) (*LearningCourse, error) {
|
||||
c := &LearningCourse{}
|
||||
var coverImg sql.NullString
|
||||
var tags []byte
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
|
||||
base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at
|
||||
FROM learning_courses WHERE slug = $1`, slug).Scan(
|
||||
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
|
||||
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
|
||||
&c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if coverImg.Valid {
|
||||
c.CoverImage = coverImg.String
|
||||
}
|
||||
json.Unmarshal(tags, &c.Tags)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetCourseByID(ctx context.Context, id string) (*LearningCourse, error) {
|
||||
c := &LearningCourse{}
|
||||
var coverImg sql.NullString
|
||||
var tags []byte
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours,
|
||||
base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at
|
||||
FROM learning_courses WHERE id = $1`, id).Scan(
|
||||
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags,
|
||||
&c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg,
|
||||
&c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if coverImg.Valid {
|
||||
c.CoverImage = coverImg.String
|
||||
}
|
||||
json.Unmarshal(tags, &c.Tags)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) CreateCourse(ctx context.Context, c *LearningCourse) error {
|
||||
tagsJSON, _ := json.Marshal(c.Tags)
|
||||
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_courses
|
||||
(slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, fingerprint, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, updated_at`,
|
||||
c.Slug, c.Title, c.ShortDescription, c.Category, string(tagsJSON), c.Difficulty, c.DurationHours,
|
||||
c.BaseOutline, c.Landing, sql.NullString{String: c.CoverImage, Valid: c.CoverImage != ""},
|
||||
sql.NullString{String: c.Fingerprint, Valid: c.Fingerprint != ""}, c.Status,
|
||||
).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpdateCourseStatus(ctx context.Context, id, status string) error {
|
||||
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_courses SET status=$2, updated_at=NOW() WHERE id=$1", id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LearningRepository) FingerprintExists(ctx context.Context, fp string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE fingerprint=$1)", fp).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// --- User profiles ---
|
||||
|
||||
func (r *LearningRepository) GetProfile(ctx context.Context, userID string) (*LearningUserProfile, error) {
|
||||
p := &LearningUserProfile{}
|
||||
var resumeFileID sql.NullString
|
||||
var resumeText sql.NullString
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT user_id, display_name, profile, resume_file_id, resume_extracted_text,
|
||||
onboarding_completed, created_at, updated_at FROM learning_user_profiles WHERE user_id=$1`, userID).Scan(
|
||||
&p.UserID, &p.DisplayName, &p.Profile, &resumeFileID, &resumeText,
|
||||
&p.OnboardingCompleted, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resumeFileID.Valid {
|
||||
p.ResumeFileID = &resumeFileID.String
|
||||
}
|
||||
if resumeText.Valid {
|
||||
p.ResumeExtractedText = resumeText.String
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpsertProfile(ctx context.Context, p *LearningUserProfile) error {
|
||||
_, err := r.db.db.ExecContext(ctx, `INSERT INTO learning_user_profiles (user_id, display_name, profile, resume_file_id, resume_extracted_text, onboarding_completed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name=EXCLUDED.display_name, profile=EXCLUDED.profile, resume_file_id=EXCLUDED.resume_file_id,
|
||||
resume_extracted_text=EXCLUDED.resume_extracted_text, onboarding_completed=EXCLUDED.onboarding_completed, updated_at=NOW()`,
|
||||
p.UserID, p.DisplayName, p.Profile,
|
||||
sql.NullString{String: func() string { if p.ResumeFileID != nil { return *p.ResumeFileID }; return "" }(), Valid: p.ResumeFileID != nil},
|
||||
sql.NullString{String: p.ResumeExtractedText, Valid: p.ResumeExtractedText != ""},
|
||||
p.OnboardingCompleted)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
func (r *LearningRepository) CreateEnrollment(ctx context.Context, e *LearningEnrollment) error {
|
||||
err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_enrollments (user_id, course_id, status, plan, progress)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at, updated_at`,
|
||||
e.UserID, e.CourseID, e.Status, e.Plan, e.Progress).Scan(&e.ID, &e.CreatedAt, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.db.db.ExecContext(ctx, "UPDATE learning_courses SET enrolled_count = enrolled_count + 1 WHERE id=$1", e.CourseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetEnrollment(ctx context.Context, id string) (*LearningEnrollment, error) {
|
||||
e := &LearningEnrollment{}
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, course_id, status, plan, progress, created_at, updated_at
|
||||
FROM learning_enrollments WHERE id=$1`, id).Scan(
|
||||
&e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) ListEnrollments(ctx context.Context, userID string) ([]*LearningEnrollment, error) {
|
||||
rows, err := r.db.db.QueryContext(ctx, `SELECT e.id, e.user_id, e.course_id, e.status, e.plan, e.progress, e.created_at, e.updated_at,
|
||||
c.id, c.slug, c.title, c.short_description, c.category, c.difficulty, c.duration_hours, c.cover_image, c.status
|
||||
FROM learning_enrollments e JOIN learning_courses c ON e.course_id=c.id WHERE e.user_id=$1 ORDER BY e.updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var enrollments []*LearningEnrollment
|
||||
for rows.Next() {
|
||||
e := &LearningEnrollment{}
|
||||
c := &LearningCourse{}
|
||||
var coverImg sql.NullString
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt,
|
||||
&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &c.Difficulty, &c.DurationHours, &coverImg, &c.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if coverImg.Valid {
|
||||
c.CoverImage = coverImg.String
|
||||
}
|
||||
e.Course = c
|
||||
enrollments = append(enrollments, e)
|
||||
}
|
||||
return enrollments, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpdateEnrollmentProgress(ctx context.Context, id string, progress json.RawMessage) error {
|
||||
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET progress=$2, updated_at=NOW() WHERE id=$1", id, progress)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpdateEnrollmentPlan(ctx context.Context, id string, plan json.RawMessage) error {
|
||||
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET plan=$2, updated_at=NOW() WHERE id=$1", id, plan)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
func (r *LearningRepository) CreateTask(ctx context.Context, t *LearningTask) error {
|
||||
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_tasks
|
||||
(enrollment_id, module_index, title, task_type, instructions_md, rubric, sandbox_template, verification_cmd, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`,
|
||||
t.EnrollmentID, t.ModuleIndex, t.Title, t.TaskType, t.InstructionsMD, t.Rubric,
|
||||
t.SandboxTemplate, t.VerificationCmd, t.Status).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetTask(ctx context.Context, id string) (*LearningTask, error) {
|
||||
t := &LearningTask{}
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md,
|
||||
rubric, sandbox_template, verification_cmd, status, created_at, updated_at
|
||||
FROM learning_tasks WHERE id=$1`, id).Scan(
|
||||
&t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD,
|
||||
&t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) ListTasksByEnrollment(ctx context.Context, enrollmentID string) ([]*LearningTask, error) {
|
||||
rows, err := r.db.db.QueryContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md,
|
||||
rubric, sandbox_template, verification_cmd, status, created_at, updated_at
|
||||
FROM learning_tasks WHERE enrollment_id=$1 ORDER BY module_index, created_at`, enrollmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []*LearningTask
|
||||
for rows.Next() {
|
||||
t := &LearningTask{}
|
||||
if err := rows.Scan(&t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD,
|
||||
&t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpdateTaskStatus(ctx context.Context, id, status string) error {
|
||||
_, err := r.db.db.ExecContext(ctx, "UPDATE learning_tasks SET status=$2, updated_at=NOW() WHERE id=$1", id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
func (r *LearningRepository) CreateSubmission(ctx context.Context, s *LearningSubmission) error {
|
||||
return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_submissions
|
||||
(task_id, sandbox_session_id, result, score, max_score, feedback_md) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, created_at`,
|
||||
s.TaskID, sql.NullString{String: func() string { if s.SandboxSessionID != nil { return *s.SandboxSessionID }; return "" }(), Valid: s.SandboxSessionID != nil},
|
||||
s.Result, s.Score, s.MaxScore, s.FeedbackMD).Scan(&s.ID, &s.CreatedAt)
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetLatestSubmission(ctx context.Context, taskID string) (*LearningSubmission, error) {
|
||||
s := &LearningSubmission{}
|
||||
var sessID sql.NullString
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, task_id, sandbox_session_id, result, score, max_score, feedback_md, created_at
|
||||
FROM learning_submissions WHERE task_id=$1 ORDER BY created_at DESC LIMIT 1`, taskID).Scan(
|
||||
&s.ID, &s.TaskID, &sessID, &s.Result, &s.Score, &s.MaxScore, &s.FeedbackMD, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sessID.Valid {
|
||||
s.SandboxSessionID = &sessID.String
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// --- Trends ---
|
||||
|
||||
func (r *LearningRepository) CreateTrend(ctx context.Context, t *LearningTrendCandidate) error {
|
||||
err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_trend_candidates (topic, category, signals, score, fingerprint)
|
||||
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (fingerprint) DO NOTHING RETURNING id, created_at`,
|
||||
t.Topic, t.Category, t.Signals, t.Score, t.Fingerprint).Scan(&t.ID, &t.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LearningRepository) PickTopTrend(ctx context.Context) (*LearningTrendCandidate, error) {
|
||||
t := &LearningTrendCandidate{}
|
||||
var lastErr sql.NullString
|
||||
var lastFailed sql.NullTime
|
||||
err := r.db.db.QueryRowContext(ctx, `UPDATE learning_trend_candidates SET picked_at=NOW()
|
||||
WHERE id = (
|
||||
SELECT id FROM learning_trend_candidates
|
||||
WHERE picked_at IS NULL
|
||||
AND fail_count < 5
|
||||
AND (last_failed_at IS NULL OR last_failed_at < NOW() - INTERVAL '15 minutes')
|
||||
ORDER BY score DESC, fail_count ASC, created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, topic, category, signals, score, fingerprint, fail_count, last_error, last_failed_at, created_at`).Scan(
|
||||
&t.ID, &t.Topic, &t.Category, &t.Signals, &t.Score, &t.Fingerprint, &t.FailCount, &lastErr, &lastFailed, &t.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastErr.Valid {
|
||||
t.LastError = &lastErr.String
|
||||
}
|
||||
if lastFailed.Valid {
|
||||
t.LastFailedAt = &lastFailed.Time
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) MarkTrendFailed(ctx context.Context, id, errMsg string) error {
|
||||
_, err := r.db.db.ExecContext(ctx, `UPDATE learning_trend_candidates
|
||||
SET fail_count = fail_count + 1,
|
||||
last_error = $2,
|
||||
last_failed_at = NOW(),
|
||||
picked_at = NULL
|
||||
WHERE id = $1`, id, errMsg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LearningRepository) SlugExists(ctx context.Context, slug string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE slug=$1)", slug).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// --- Sandbox sessions ---
|
||||
|
||||
func (r *LearningRepository) CreateSandboxSession(ctx context.Context, s *SandboxSession) error {
|
||||
return r.db.db.QueryRowContext(ctx, `INSERT INTO sandbox_sessions (user_id, task_id, opensandbox_id, status, metadata)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at`,
|
||||
s.UserID, sql.NullString{String: func() string { if s.TaskID != nil { return *s.TaskID }; return "" }(), Valid: s.TaskID != nil},
|
||||
s.OpenSandboxID, s.Status, s.Metadata).Scan(&s.ID, &s.CreatedAt)
|
||||
}
|
||||
|
||||
func (r *LearningRepository) GetSandboxSession(ctx context.Context, id string) (*SandboxSession, error) {
|
||||
s := &SandboxSession{}
|
||||
var taskID sql.NullString
|
||||
err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, task_id, opensandbox_id, status, last_active_at, metadata, created_at
|
||||
FROM sandbox_sessions WHERE id=$1`, id).Scan(
|
||||
&s.ID, &s.UserID, &taskID, &s.OpenSandboxID, &s.Status, &s.LastActiveAt, &s.Metadata, &s.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskID.Valid {
|
||||
s.TaskID = &taskID.String
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *LearningRepository) UpdateSandboxSessionStatus(ctx context.Context, id, status string) error {
|
||||
_, err := r.db.db.ExecContext(ctx, "UPDATE sandbox_sessions SET status=$2, last_active_at=NOW() WHERE id=$1", id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *LearningRepository) CreateSandboxEvent(ctx context.Context, sessionID, eventType string, payload json.RawMessage) error {
|
||||
_, err := r.db.db.ExecContext(ctx, `INSERT INTO sandbox_events (session_id, event_type, payload) VALUES ($1,$2,$3)`,
|
||||
sessionID, eventType, payload)
|
||||
return err
|
||||
}
|
||||
43
backend/internal/db/learning_repo_test.go
Normal file
43
backend/internal/db/learning_repo_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLearningRepository(t *testing.T) {
|
||||
pg := &PostgresDB{}
|
||||
repo := NewLearningRepository(pg)
|
||||
if repo == nil {
|
||||
t.Fatalf("expected repository instance")
|
||||
}
|
||||
if repo.db != pg {
|
||||
t.Fatalf("repository must keep provided db pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearningUserProfileJSONContract(t *testing.T) {
|
||||
profile := LearningUserProfile{
|
||||
UserID: "u-1",
|
||||
DisplayName: "Alex",
|
||||
Profile: json.RawMessage(`{"target_track":"backend"}`),
|
||||
ResumeExtractedText: "resume text",
|
||||
OnboardingCompleted: true,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal profile: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal profile json: %v", err)
|
||||
}
|
||||
if decoded["userId"] != "u-1" {
|
||||
t.Fatalf("unexpected userId: %v", decoded["userId"])
|
||||
}
|
||||
if decoded["onboardingCompleted"] != true {
|
||||
t.Fatalf("unexpected onboardingCompleted: %v", decoded["onboardingCompleted"])
|
||||
}
|
||||
}
|
||||
556
backend/internal/learning/course_autogen.go
Normal file
556
backend/internal/learning/course_autogen.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
)
|
||||
|
||||
type CourseAutoGenConfig struct {
|
||||
LLM llm.Client
|
||||
Repo *db.LearningRepository
|
||||
SearchClient *search.SearXNGClient
|
||||
}
|
||||
|
||||
type CourseAutoGenerator struct {
|
||||
cfg CourseAutoGenConfig
|
||||
}
|
||||
|
||||
func NewCourseAutoGenerator(cfg CourseAutoGenConfig) *CourseAutoGenerator {
|
||||
return &CourseAutoGenerator{cfg: cfg}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) StartBackground(ctx context.Context) {
|
||||
log.Println("[course-autogen] starting background course generation")
|
||||
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(2 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
g.runCycle(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
g.runCycle(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) runCycle(ctx context.Context) {
|
||||
log.Println("[course-autogen] running generation cycle")
|
||||
|
||||
cycleCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := g.collectTrends(cycleCtx); err != nil {
|
||||
log.Printf("[course-autogen] trend collection error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
trend, err := g.cfg.Repo.PickTopTrend(cycleCtx)
|
||||
if err != nil || trend == nil {
|
||||
log.Printf("[course-autogen] no more trends to process")
|
||||
break
|
||||
}
|
||||
|
||||
if err := g.designAndPublishCourse(cycleCtx, trend); err != nil {
|
||||
log.Printf("[course-autogen] course design error for '%s': %v", trend.Topic, err)
|
||||
continue
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) collectTrends(ctx context.Context) error {
|
||||
var webContext string
|
||||
|
||||
if g.cfg.SearchClient != nil {
|
||||
webContext = g.searchTrendData(ctx)
|
||||
}
|
||||
|
||||
prompt := `Ты — аналитик трендов IT-индустрии и образования в России и мире.`
|
||||
|
||||
if webContext != "" {
|
||||
prompt += "\n\nРЕАЛЬНЫЕ ДАННЫЕ ИЗ ИНТЕРНЕТА:\n" + webContext
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
На основе реальных данных выбери 5 уникальных тем для курсов:
|
||||
|
||||
КРИТЕРИИ:
|
||||
1. Актуальны на рынке РФ (вакансии hh.ru, habr, стеки)
|
||||
2. НЕ банальные ("Основы Python", "HTML для начинающих" — НЕТ)
|
||||
3. Практическая ценность для карьеры и зарплаты
|
||||
4. Уникальность — чего нет на Stepik/Coursera/Skillbox
|
||||
5. Тренды 2025-2026: AI/ML ops, platform engineering, Rust, WebAssembly, edge computing и т.д.
|
||||
|
||||
Категории: programming, devops, data, ai_ml, security, product, design, management, fintech, gamedev, mobile, blockchain, iot, other
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"trends": [
|
||||
{
|
||||
"topic": "Конкретное название курса",
|
||||
"category": "категория",
|
||||
"why_unique": "Почему этот курс уникален и привлечёт пользователей",
|
||||
"demand_signals": ["сигнал спроса 1", "сигнал спроса 2"],
|
||||
"target_salary": "ожидаемая зарплата после курса",
|
||||
"score": 0.85
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
}, 2, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
var parsed struct {
|
||||
Trends []struct {
|
||||
Topic string `json:"topic"`
|
||||
Category string `json:"category"`
|
||||
WhyUnique string `json:"why_unique"`
|
||||
DemandSignals []string `json:"demand_signals"`
|
||||
TargetSalary string `json:"target_salary"`
|
||||
Score float64 `json:"score"`
|
||||
} `json:"trends"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil || len(parsed.Trends) == 0 {
|
||||
// Try a strict repair prompt once (common provider failure mode: extra prose / malformed JSON)
|
||||
repairPrompt := "Верни ответ СТРОГО как JSON без текста. " + prompt
|
||||
repaired, rerr := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: repairPrompt}},
|
||||
}, 1, 2*time.Second)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("failed to parse trends: %w", err)
|
||||
}
|
||||
jsonStr = extractJSONBlock(repaired)
|
||||
if uerr := json.Unmarshal([]byte(jsonStr), &parsed); uerr != nil || len(parsed.Trends) == 0 {
|
||||
if uerr != nil {
|
||||
return fmt.Errorf("failed to parse trends: %w", uerr)
|
||||
}
|
||||
return fmt.Errorf("failed to parse trends: empty trends")
|
||||
}
|
||||
}
|
||||
|
||||
saved := 0
|
||||
for _, t := range parsed.Trends {
|
||||
fp := generateFingerprint(t.Topic)
|
||||
|
||||
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
signals, _ := json.Marshal(map[string]interface{}{
|
||||
"why_unique": t.WhyUnique,
|
||||
"demand_signals": t.DemandSignals,
|
||||
"target_salary": t.TargetSalary,
|
||||
})
|
||||
|
||||
trend := &db.LearningTrendCandidate{
|
||||
Topic: t.Topic,
|
||||
Category: t.Category,
|
||||
Signals: signals,
|
||||
Score: t.Score,
|
||||
Fingerprint: fp,
|
||||
}
|
||||
if err := g.cfg.Repo.CreateTrend(ctx, trend); err == nil {
|
||||
saved++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[course-autogen] saved %d new trend candidates", saved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) searchTrendData(ctx context.Context) string {
|
||||
queries := []string{
|
||||
"IT тренды обучение 2025 2026 Россия",
|
||||
"самые востребованные IT навыки вакансии hh.ru",
|
||||
"новые технологии программирование курсы",
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
snippet := r.Title + ": " + r.Content
|
||||
if len(snippet) > 300 {
|
||||
snippet = snippet[:300]
|
||||
}
|
||||
results = append(results, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
combined := strings.Join(results, "\n---\n")
|
||||
if len(combined) > 3000 {
|
||||
combined = combined[:3000]
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) designAndPublishCourse(ctx context.Context, trend *db.LearningTrendCandidate) error {
|
||||
log.Printf("[course-autogen] designing course: %s", trend.Topic)
|
||||
|
||||
fp := generateFingerprint(trend.Topic)
|
||||
exists, _ := g.cfg.Repo.FingerprintExists(ctx, fp)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var marketResearch string
|
||||
if g.cfg.SearchClient != nil {
|
||||
marketResearch = g.researchCourseTopic(ctx, trend.Topic)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT. Спроектируй профессиональный курс.
|
||||
|
||||
Тема: %s
|
||||
Категория: %s`, trend.Topic, trend.Category)
|
||||
|
||||
if marketResearch != "" {
|
||||
prompt += "\n\nИССЛЕДОВАНИЕ РЫНКА:\n" + marketResearch
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
ТРЕБОВАНИЯ:
|
||||
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
|
||||
2. Каждый модуль — практическое задание из реального проекта
|
||||
3. Уровень: от базового до продвинутого
|
||||
4. Курс должен быть уникальным — не копия Stepik/Coursera
|
||||
5. Лендинг должен ПРОДАВАТЬ — конкретные выгоды, зарплаты, результаты
|
||||
6. Outline должен быть детальным — 8-12 модулей
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"title": "Привлекательное название курса",
|
||||
"slug": "slug-without-spaces",
|
||||
"short_description": "Краткое описание 2-3 предложения. Конкретика, не вода.",
|
||||
"difficulty": "beginner|intermediate|advanced",
|
||||
"duration_hours": 40,
|
||||
"tags": ["тег1", "тег2"],
|
||||
"outline": {
|
||||
"modules": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Название модуля",
|
||||
"description": "Описание + что делаем на практике",
|
||||
"skills": ["навык"],
|
||||
"estimated_hours": 4,
|
||||
"practice_focus": "Конкретная практическая задача"
|
||||
}
|
||||
]
|
||||
},
|
||||
"landing": {
|
||||
"hero_title": "Заголовок лендинга (продающий)",
|
||||
"hero_subtitle": "Подзаголовок с конкретной выгодой",
|
||||
"benefits": ["Конкретная выгода 1", "Выгода 2", "Выгода 3", "Выгода 4"],
|
||||
"target_audience": "Для кого этот курс — конкретно",
|
||||
"outcomes": ["Результат 1 с цифрами", "Результат 2"],
|
||||
"salary_range": "Ожидаемая зарплата после курса",
|
||||
"prerequisites": "Что нужно знать заранее",
|
||||
"faq": [
|
||||
{"question": "Вопрос?", "answer": "Ответ"}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := generateTextWithRetry(ctx, g.cfg.LLM, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
}, 2, 2*time.Second)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
var parsed struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
ShortDescription string `json:"short_description"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
DurationHours int `json:"duration_hours"`
|
||||
Tags []string `json:"tags"`
|
||||
Outline json.RawMessage `json:"outline"`
|
||||
Landing json.RawMessage `json:"landing"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
lastErr = fmt.Errorf("failed to parse course design: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
outlineJSON := parsed.Outline
|
||||
if outlineJSON == nil {
|
||||
outlineJSON = json.RawMessage("{}")
|
||||
}
|
||||
landingJSON := parsed.Landing
|
||||
if landingJSON == nil {
|
||||
landingJSON = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
if err := validateCourseArtifacts(parsed.Title, parsed.ShortDescription, outlineJSON, landingJSON); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(parsed.Slug)
|
||||
if slug == "" {
|
||||
slug = sanitizeSlug(parsed.Title)
|
||||
}
|
||||
slug = g.ensureUniqueSlug(ctx, slug)
|
||||
|
||||
if parsed.DurationHours == 0 {
|
||||
parsed.DurationHours = 40
|
||||
}
|
||||
parsed.Difficulty = normalizeDifficulty(parsed.Difficulty)
|
||||
|
||||
course := &db.LearningCourse{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(parsed.Title),
|
||||
ShortDescription: strings.TrimSpace(parsed.ShortDescription),
|
||||
Category: trend.Category,
|
||||
Tags: parsed.Tags,
|
||||
Difficulty: parsed.Difficulty,
|
||||
DurationHours: parsed.DurationHours,
|
||||
BaseOutline: outlineJSON,
|
||||
Landing: landingJSON,
|
||||
Fingerprint: fp,
|
||||
Status: "published",
|
||||
}
|
||||
|
||||
if err := g.cfg.Repo.CreateCourse(ctx, course); err != nil {
|
||||
lastErr = fmt.Errorf("failed to save course: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[course-autogen] published course: %s (%s)", course.Title, course.Slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("unknown course design failure")
|
||||
}
|
||||
_ = g.cfg.Repo.MarkTrendFailed(ctx, trend.ID, truncateErr(lastErr.Error(), 800))
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) researchCourseTopic(ctx context.Context, topic string) string {
|
||||
queries := []string{
|
||||
topic + " курс программа обучение",
|
||||
topic + " вакансии зарплата Россия",
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := g.cfg.SearchClient.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
snippet := r.Title + ": " + r.Content
|
||||
if len(snippet) > 250 {
|
||||
snippet = snippet[:250]
|
||||
}
|
||||
results = append(results, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
combined := strings.Join(results, "\n---\n")
|
||||
if len(combined) > 2000 {
|
||||
combined = combined[:2000]
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func generateFingerprint(topic string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(topic))
|
||||
hash := sha256.Sum256([]byte(normalized))
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var result []rune
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
result = append(result, r)
|
||||
} else if r == ' ' || r == '-' || r == '_' {
|
||||
result = append(result, '-')
|
||||
}
|
||||
}
|
||||
slug := string(result)
|
||||
re := regexp.MustCompile(`-+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 100 {
|
||||
slug = slug[:100]
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func (g *CourseAutoGenerator) ensureUniqueSlug(ctx context.Context, base string) string {
|
||||
if base == "" {
|
||||
base = "course"
|
||||
}
|
||||
slug := base
|
||||
for i := 0; i < 20; i++ {
|
||||
exists, err := g.cfg.Repo.SlugExists(ctx, slug)
|
||||
if err == nil && !exists {
|
||||
return slug
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i+2)
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix()%10000)
|
||||
}
|
||||
|
||||
func normalizeDifficulty(d string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(d)) {
|
||||
case "beginner", "intermediate", "advanced":
|
||||
return strings.ToLower(strings.TrimSpace(d))
|
||||
default:
|
||||
return "intermediate"
|
||||
}
|
||||
}
|
||||
|
||||
func validateCourseArtifacts(title, short string, outlineJSON, landingJSON json.RawMessage) error {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return errors.New("course title is empty")
|
||||
}
|
||||
if len(strings.TrimSpace(short)) < 40 {
|
||||
return errors.New("short_description слишком короткое (нужна конкретика)")
|
||||
}
|
||||
|
||||
// Outline validation
|
||||
var outline struct {
|
||||
Modules []struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Skills []string `json:"skills"`
|
||||
EstimatedHours int `json:"estimated_hours"`
|
||||
PracticeFocus string `json:"practice_focus"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(outlineJSON, &outline); err != nil {
|
||||
return fmt.Errorf("outline JSON invalid: %w", err)
|
||||
}
|
||||
if len(outline.Modules) < 8 || len(outline.Modules) > 12 {
|
||||
return fmt.Errorf("outline modules count must be 8-12, got %d", len(outline.Modules))
|
||||
}
|
||||
for i, m := range outline.Modules {
|
||||
if strings.TrimSpace(m.Title) == "" || strings.TrimSpace(m.PracticeFocus) == "" {
|
||||
return fmt.Errorf("outline module[%d] missing title/practice_focus", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Landing validation
|
||||
var landing struct {
|
||||
HeroTitle string `json:"hero_title"`
|
||||
HeroSubtitle string `json:"hero_subtitle"`
|
||||
Benefits []string `json:"benefits"`
|
||||
Outcomes []string `json:"outcomes"`
|
||||
SalaryRange string `json:"salary_range"`
|
||||
FAQ []struct {
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
} `json:"faq"`
|
||||
}
|
||||
if err := json.Unmarshal(landingJSON, &landing); err != nil {
|
||||
return fmt.Errorf("landing JSON invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(landing.HeroTitle) == "" || strings.TrimSpace(landing.HeroSubtitle) == "" {
|
||||
return errors.New("landing missing hero_title/hero_subtitle")
|
||||
}
|
||||
if len(landing.Benefits) < 3 || len(landing.Outcomes) < 2 {
|
||||
return errors.New("landing benefits/outcomes недостаточно конкретные")
|
||||
}
|
||||
if strings.TrimSpace(landing.SalaryRange) == "" {
|
||||
return errors.New("landing missing salary_range")
|
||||
}
|
||||
if len(landing.FAQ) < 1 || strings.TrimSpace(landing.FAQ[0].Question) == "" {
|
||||
return errors.New("landing FAQ missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateTextWithRetry(ctx context.Context, client llm.Client, req llm.StreamRequest, retries int, baseDelay time.Duration) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt-1))
|
||||
t := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return "", ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
res, err := client.GenerateText(ctx, req)
|
||||
if err == nil && strings.TrimSpace(res) != "" {
|
||||
return res, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("empty response")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func truncateErr(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
79
backend/internal/learning/course_autogen_test.go
Normal file
79
backend/internal/learning/course_autogen_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestSanitizeSlug(t *testing.T) {
|
||||
got := sanitizeSlug(" Go Backend: Production _ Course ")
|
||||
if got != "go-backend-production-course" {
|
||||
t.Fatalf("sanitizeSlug result mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDifficulty(t *testing.T) {
|
||||
if normalizeDifficulty("advanced") != "advanced" {
|
||||
t.Fatalf("expected advanced difficulty")
|
||||
}
|
||||
if normalizeDifficulty("unknown") != "intermediate" {
|
||||
t.Fatalf("expected fallback to intermediate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCourseArtifacts(t *testing.T) {
|
||||
outline := json.RawMessage(`{
|
||||
"modules": [
|
||||
{"index":0,"title":"m1","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":1,"title":"m2","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":2,"title":"m3","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":3,"title":"m4","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":4,"title":"m5","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":5,"title":"m6","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":6,"title":"m7","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"},
|
||||
{"index":7,"title":"m8","description":"d","skills":["a"],"estimated_hours":4,"practice_focus":"p"}
|
||||
]
|
||||
}`)
|
||||
landing := json.RawMessage(`{
|
||||
"hero_title":"Hero",
|
||||
"hero_subtitle":"Subtitle",
|
||||
"benefits":["b1","b2","b3"],
|
||||
"outcomes":["o1","o2"],
|
||||
"salary_range":"200k",
|
||||
"faq":[{"question":"q","answer":"a"}]
|
||||
}`)
|
||||
|
||||
err := validateCourseArtifacts("Course", "Это достаточно длинное описание для валидации курса в тесте.", outline, landing)
|
||||
if err != nil {
|
||||
t.Fatalf("validateCourseArtifacts unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextWithRetry(t *testing.T) {
|
||||
attempt := 0
|
||||
client := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
attempt++
|
||||
if attempt < 3 {
|
||||
return "", errors.New("temporary")
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
}
|
||||
|
||||
got, err := generateTextWithRetry(context.Background(), client, llm.StreamRequest{}, 3, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("generateTextWithRetry error: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("unexpected result: %q", got)
|
||||
}
|
||||
if attempt != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", attempt)
|
||||
}
|
||||
}
|
||||
36
backend/internal/learning/mock_llm_test.go
Normal file
36
backend/internal/learning/mock_llm_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type mockLLMClient struct {
|
||||
generateFunc func(ctx context.Context, req llm.StreamRequest) (string, error)
|
||||
streamFunc func(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error)
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) StreamText(ctx context.Context, req llm.StreamRequest) (<-chan llm.StreamChunk, error) {
|
||||
if m.streamFunc != nil {
|
||||
return m.streamFunc(ctx, req)
|
||||
}
|
||||
ch := make(chan llm.StreamChunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GenerateText(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
if m.generateFunc != nil {
|
||||
return m.generateFunc(ctx, req)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GetProviderID() string {
|
||||
return "mock"
|
||||
}
|
||||
|
||||
func (m *mockLLMClient) GetModelKey() string {
|
||||
return "mock-model"
|
||||
}
|
||||
118
backend/internal/learning/plan_builder.go
Normal file
118
backend/internal/learning/plan_builder.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type PersonalPlan struct {
|
||||
Modules []PlanModule `json:"modules"`
|
||||
TotalHours int `json:"total_hours"`
|
||||
DifficultyAdjusted string `json:"difficulty_adjusted"`
|
||||
PersonalizationNote string `json:"personalization_notes"`
|
||||
MilestoneProject string `json:"milestone_project"`
|
||||
}
|
||||
|
||||
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"`
|
||||
IsCheckpoint bool `json:"is_checkpoint"`
|
||||
}
|
||||
|
||||
func BuildPersonalPlan(ctx context.Context, llmClient llm.Client, course *db.LearningCourse, profileJSON string) (json.RawMessage, error) {
|
||||
profileInfo := "Профиль неизвестен — план по умолчанию."
|
||||
if profileJSON != "" && profileJSON != "{}" {
|
||||
profileInfo = "Профиль ученика:\n" + truncateStr(profileJSON, 2000)
|
||||
}
|
||||
|
||||
outlineStr := string(course.BaseOutline)
|
||||
if len(outlineStr) > 4000 {
|
||||
outlineStr = outlineStr[:4000]
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Ты — ведущий методолог обучения в IT с 10-летним опытом. Адаптируй базовый план курса под конкретного ученика.
|
||||
|
||||
Курс: %s
|
||||
Описание: %s
|
||||
Сложность: %s
|
||||
Длительность: %d часов
|
||||
|
||||
Базовый план:
|
||||
%s
|
||||
|
||||
%s
|
||||
|
||||
ТРЕБОВАНИЯ:
|
||||
1. Минимум теории, максимум боевой практики (как на реальных проектах в РФ)
|
||||
2. Каждый модуль = конкретное практическое задание из реального проекта
|
||||
3. Прогрессия: от простого к сложному, учитывая текущий уровень ученика
|
||||
4. Каждый 3-й модуль — checkpoint (мини-проект для проверки навыков)
|
||||
5. Финальный milestone project — полноценный проект для портфолио
|
||||
6. Учитывай стек и опыт ученика — не повторяй то, что он уже знает
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Название модуля",
|
||||
"description": "Что изучаем и делаем",
|
||||
"skills": ["навык1"],
|
||||
"estimated_hours": 4,
|
||||
"practice_focus": "Конкретная практическая задача из реального проекта",
|
||||
"task_count": 3,
|
||||
"is_checkpoint": false
|
||||
}
|
||||
],
|
||||
"total_hours": 40,
|
||||
"difficulty_adjusted": "intermediate",
|
||||
"personalization_notes": "Как план адаптирован под ученика",
|
||||
"milestone_project": "Описание финального проекта для портфолио"
|
||||
}`, course.Title, course.ShortDescription, course.Difficulty, course.DurationHours, outlineStr, profileInfo)
|
||||
|
||||
var plan PersonalPlan
|
||||
if err := generateAndParse(ctx, llmClient, prompt, &plan, 2); err != nil {
|
||||
return course.BaseOutline, fmt.Errorf("plan generation failed, using base outline: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Modules) == 0 {
|
||||
return course.BaseOutline, nil
|
||||
}
|
||||
|
||||
for i := range plan.Modules {
|
||||
plan.Modules[i].Index = i
|
||||
if plan.Modules[i].TaskCount == 0 {
|
||||
plan.Modules[i].TaskCount = 2
|
||||
}
|
||||
}
|
||||
|
||||
if plan.TotalHours == 0 {
|
||||
total := 0
|
||||
for _, m := range plan.Modules {
|
||||
total += m.EstimatedHrs
|
||||
}
|
||||
plan.TotalHours = total
|
||||
}
|
||||
|
||||
result, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return course.BaseOutline, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
66
backend/internal/learning/plan_builder_test.go
Normal file
66
backend/internal/learning/plan_builder_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gooseek/backend/internal/db"
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestBuildPersonalPlanAppliesDefaults(t *testing.T) {
|
||||
course := &db.LearningCourse{
|
||||
Title: "Go Backend",
|
||||
ShortDescription: "Курс по backend-разработке на Go",
|
||||
Difficulty: "intermediate",
|
||||
DurationHours: 24,
|
||||
BaseOutline: json.RawMessage(`{"modules":[{"index":0,"title":"base","description":"base","skills":["go"],"estimated_hours":4,"practice_focus":"api"}]}`),
|
||||
}
|
||||
|
||||
client := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
return `{
|
||||
"modules": [
|
||||
{"index": 999, "title": "API design", "description": "design REST", "skills": ["http"], "estimated_hours": 6, "practice_focus": "build handlers", "task_count": 0},
|
||||
{"index": 999, "title": "DB layer", "description": "storage", "skills": ["sql"], "estimated_hours": 8, "practice_focus": "repository pattern", "task_count": 3}
|
||||
],
|
||||
"total_hours": 0,
|
||||
"difficulty_adjusted": "intermediate",
|
||||
"personalization_notes": "adapted"
|
||||
}`, nil
|
||||
},
|
||||
}
|
||||
|
||||
planJSON, err := BuildPersonalPlan(context.Background(), client, course, `{"level":"junior"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPersonalPlan error: %v", err)
|
||||
}
|
||||
|
||||
var plan PersonalPlan
|
||||
if err := json.Unmarshal(planJSON, &plan); err != nil {
|
||||
t.Fatalf("unmarshal plan: %v", err)
|
||||
}
|
||||
|
||||
if len(plan.Modules) != 2 {
|
||||
t.Fatalf("expected 2 modules, got %d", len(plan.Modules))
|
||||
}
|
||||
if plan.Modules[0].Index != 0 || plan.Modules[1].Index != 1 {
|
||||
t.Fatalf("module indexes were not normalized: %+v", plan.Modules)
|
||||
}
|
||||
if plan.Modules[0].TaskCount != 2 {
|
||||
t.Fatalf("expected default task_count=2, got %d", plan.Modules[0].TaskCount)
|
||||
}
|
||||
if plan.TotalHours != 14 {
|
||||
t.Fatalf("expected total_hours=14, got %d", plan.TotalHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
if got := truncateStr("abc", 5); got != "abc" {
|
||||
t.Fatalf("truncateStr should keep short string, got %q", got)
|
||||
}
|
||||
if got := truncateStr("abcdef", 3); got != "abc..." {
|
||||
t.Fatalf("truncateStr should truncate with ellipsis, got %q", got)
|
||||
}
|
||||
}
|
||||
203
backend/internal/learning/profile_builder.go
Normal file
203
backend/internal/learning/profile_builder.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type UserProfile struct {
|
||||
Name string `json:"name"`
|
||||
ExperienceYears float64 `json:"experience_years"`
|
||||
CurrentRole string `json:"current_role"`
|
||||
Skills []string `json:"skills"`
|
||||
ProgrammingLangs []string `json:"programming_languages"`
|
||||
Frameworks []string `json:"frameworks"`
|
||||
Education string `json:"education"`
|
||||
Industries []string `json:"industries"`
|
||||
Strengths []string `json:"strengths"`
|
||||
GrowthAreas []string `json:"growth_areas"`
|
||||
CareerGoals string `json:"career_goals"`
|
||||
RecommendedTracks []string `json:"recommended_tracks"`
|
||||
Level string `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
func BuildProfileFromResume(ctx context.Context, llmClient llm.Client, extractedText string) (json.RawMessage, error) {
|
||||
if strings.TrimSpace(extractedText) == "" {
|
||||
return json.RawMessage("{}"), fmt.Errorf("empty resume text")
|
||||
}
|
||||
|
||||
if len(extractedText) > 12000 {
|
||||
extractedText = extractedText[:12000]
|
||||
}
|
||||
|
||||
prompt := `Ты — senior HR-аналитик с 15-летним опытом в IT-рекрутинге в РФ. Проанализируй резюме и создай детальный профиль.
|
||||
|
||||
Резюме:
|
||||
` + extractedText + `
|
||||
|
||||
ЗАДАЧА: Извлеки максимум информации. Определи реальный уровень кандидата (не завышай).
|
||||
|
||||
Ответь строго JSON (без markdown, без комментариев):
|
||||
{
|
||||
"name": "Имя Фамилия",
|
||||
"experience_years": 3.5,
|
||||
"current_role": "текущая должность или последняя",
|
||||
"skills": ["навык1", "навык2", "навык3"],
|
||||
"programming_languages": ["Go", "Python"],
|
||||
"frameworks": ["React", "Fiber"],
|
||||
"education": "образование кратко",
|
||||
"industries": ["fintech", "ecommerce"],
|
||||
"strengths": ["сильная сторона 1", "сильная сторона 2"],
|
||||
"growth_areas": ["зона роста 1", "зона роста 2"],
|
||||
"career_goals": "предположительные цели на основе опыта",
|
||||
"recommended_tracks": ["рекомендуемый трек 1", "трек 2"],
|
||||
"level": "junior|middle|senior|lead|expert",
|
||||
"summary": "Краткая характеристика кандидата в 2-3 предложения"
|
||||
}`
|
||||
|
||||
var profile UserProfile
|
||||
err := generateAndParse(ctx, llmClient, prompt, &profile, 2)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}"), fmt.Errorf("profile extraction failed: %w", err)
|
||||
}
|
||||
|
||||
if profile.Level == "" {
|
||||
profile.Level = inferLevel(profile.ExperienceYears)
|
||||
}
|
||||
if profile.Summary == "" {
|
||||
profile.Summary = fmt.Sprintf("%s, %s, опыт %.0f лет", profile.Name, profile.CurrentRole, profile.ExperienceYears)
|
||||
}
|
||||
|
||||
result, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return json.RawMessage("{}"), err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildProfileFromOnboarding(ctx context.Context, llmClient llm.Client, answers map[string]string) (json.RawMessage, error) {
|
||||
answersJSON, _ := json.Marshal(answers)
|
||||
|
||||
prompt := `Ты — методолог обучения. На основе ответов пользователя на онбординг-вопросы, построй профиль.
|
||||
|
||||
Ответы пользователя:
|
||||
` + string(answersJSON) + `
|
||||
|
||||
Ответь строго JSON:
|
||||
{
|
||||
"name": "",
|
||||
"experience_years": 0,
|
||||
"current_role": "",
|
||||
"skills": [],
|
||||
"programming_languages": [],
|
||||
"frameworks": [],
|
||||
"education": "",
|
||||
"industries": [],
|
||||
"strengths": [],
|
||||
"growth_areas": [],
|
||||
"career_goals": "",
|
||||
"recommended_tracks": [],
|
||||
"level": "beginner|junior|middle|senior",
|
||||
"summary": "Краткая характеристика"
|
||||
}`
|
||||
|
||||
var profile UserProfile
|
||||
if err := generateAndParse(ctx, llmClient, prompt, &profile, 2); err != nil {
|
||||
return json.RawMessage("{}"), err
|
||||
}
|
||||
|
||||
if profile.Level == "" {
|
||||
profile.Level = "beginner"
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(profile)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inferLevel(years float64) string {
|
||||
switch {
|
||||
case years < 1:
|
||||
return "beginner"
|
||||
case years < 3:
|
||||
return "junior"
|
||||
case years < 5:
|
||||
return "middle"
|
||||
case years < 8:
|
||||
return "senior"
|
||||
default:
|
||||
return "lead"
|
||||
}
|
||||
}
|
||||
|
||||
func generateAndParse(ctx context.Context, llmClient llm.Client, prompt string, target interface{}, maxRetries int) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
result, err := llmClient.GenerateText(attemptCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: prompt}},
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := extractJSONBlock(result)
|
||||
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d: JSON parse error: %w", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("all %d attempts failed: %w", maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func extractJSONBlock(text string) string {
|
||||
if strings.Contains(text, "```json") {
|
||||
start := strings.Index(text, "```json") + 7
|
||||
end := strings.Index(text[start:], "```")
|
||||
if end > 0 {
|
||||
return strings.TrimSpace(text[start : start+end])
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "```") {
|
||||
start := strings.Index(text, "```") + 3
|
||||
if nl := strings.Index(text[start:], "\n"); nl >= 0 {
|
||||
start += nl + 1
|
||||
}
|
||||
end := strings.Index(text[start:], "```")
|
||||
if end > 0 {
|
||||
candidate := strings.TrimSpace(text[start : start+end])
|
||||
if len(candidate) > 2 && candidate[0] == '{' {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depth := 0
|
||||
startIdx := -1
|
||||
for i, ch := range text {
|
||||
if ch == '{' {
|
||||
if depth == 0 {
|
||||
startIdx = i
|
||||
}
|
||||
depth++
|
||||
} else if ch == '}' {
|
||||
depth--
|
||||
if depth == 0 && startIdx >= 0 {
|
||||
return text[startIdx : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "{}"
|
||||
}
|
||||
65
backend/internal/learning/profile_builder_test.go
Normal file
65
backend/internal/learning/profile_builder_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
func TestInferLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
years float64
|
||||
want string
|
||||
}{
|
||||
{0, "beginner"},
|
||||
{1.5, "junior"},
|
||||
{3.2, "middle"},
|
||||
{6.5, "senior"},
|
||||
{10, "lead"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := inferLevel(tc.years)
|
||||
if got != tc.want {
|
||||
t.Fatalf("inferLevel(%v) = %q, want %q", tc.years, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONBlockFromMarkdown(t *testing.T) {
|
||||
input := "text before\n```json\n{\"name\":\"Alex\",\"level\":\"junior\"}\n```\ntext after"
|
||||
got := extractJSONBlock(input)
|
||||
if got != "{\"name\":\"Alex\",\"level\":\"junior\"}" {
|
||||
t.Fatalf("unexpected json block: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileFromOnboarding(t *testing.T) {
|
||||
llmClient := &mockLLMClient{
|
||||
generateFunc: func(ctx context.Context, req llm.StreamRequest) (string, error) {
|
||||
return `{"name":"Иван","experience_years":1.5,"current_role":"qa","skills":["testing"],"programming_languages":["Go"],"frameworks":[],"education":"BS","industries":["it"],"strengths":["аналитика"],"growth_areas":["backend"],"career_goals":"backend","recommended_tracks":["backend go"],"level":"junior","summary":"Начинающий специалист"}`, nil
|
||||
},
|
||||
}
|
||||
|
||||
profileJSON, err := BuildProfileFromOnboarding(context.Background(), llmClient, map[string]string{
|
||||
"experience_level": "junior",
|
||||
"target_track": "backend go",
|
||||
"weekly_hours": "10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildProfileFromOnboarding error: %v", err)
|
||||
}
|
||||
|
||||
var profile map[string]interface{}
|
||||
if err := json.Unmarshal(profileJSON, &profile); err != nil {
|
||||
t.Fatalf("profile json unmarshal: %v", err)
|
||||
}
|
||||
if profile["name"] != "Иван" {
|
||||
t.Fatalf("unexpected name: %v", profile["name"])
|
||||
}
|
||||
if profile["level"] != "junior" {
|
||||
t.Fatalf("unexpected level: %v", profile["level"])
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
@@ -680,12 +679,7 @@ func (l *StepByStepLesson) SubmitQuizAnswer(stepIndex int, selectedOptions []str
|
||||
}
|
||||
|
||||
func extractJSON(text string) string {
|
||||
re := regexp.MustCompile(`(?s)\{.*\}`)
|
||||
match := re.FindString(text)
|
||||
if match != "" {
|
||||
return match
|
||||
}
|
||||
return "{}"
|
||||
return extractJSONBlock(text)
|
||||
}
|
||||
|
||||
func (l *StepByStepLesson) ToJSON() ([]byte, error) {
|
||||
|
||||
671
backend/internal/medicine/service.go
Normal file
671
backend/internal/medicine/service.go
Normal file
@@ -0,0 +1,671 @@
|
||||
package medicine
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ServiceConfig struct {
|
||||
LLM llm.Client
|
||||
SearXNGURL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
llm llm.Client
|
||||
searxngURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type ConsultRequest struct {
|
||||
Symptoms string `json:"symptoms"`
|
||||
City string `json:"city,omitempty"`
|
||||
History [][2]string `json:"history,omitempty"`
|
||||
Age int `json:"age,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
ChatID string `json:"chatId,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type ConditionItem struct {
|
||||
Name string `json:"name"`
|
||||
Likelihood string `json:"likelihood"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
type SpecialtyItem struct {
|
||||
Specialty string `json:"specialty"`
|
||||
Reason string `json:"reason"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
type MedicationInfo struct {
|
||||
Name string `json:"name"`
|
||||
ForWhat string `json:"forWhat"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type SupplementInfo struct {
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
Evidence string `json:"evidence"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type ProcedureInfo struct {
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
WhenUseful string `json:"whenUseful"`
|
||||
}
|
||||
|
||||
type Assessment struct {
|
||||
TriageLevel string `json:"triageLevel"`
|
||||
UrgentSigns []string `json:"urgentSigns"`
|
||||
PossibleConditions []ConditionItem `json:"possibleConditions"`
|
||||
RecommendedSpecialists []SpecialtyItem `json:"recommendedSpecialists"`
|
||||
QuestionsToClarify []string `json:"questionsToClarify"`
|
||||
HomeCare []string `json:"homeCare"`
|
||||
MedicationInfo []MedicationInfo `json:"medicationInfo"`
|
||||
SupplementInfo []SupplementInfo `json:"supplementInfo"`
|
||||
ProcedureInfo []ProcedureInfo `json:"procedureInfo"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
}
|
||||
|
||||
type DoctorOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Specialty string `json:"specialty"`
|
||||
Clinic string `json:"clinic"`
|
||||
City string `json:"city"`
|
||||
Address string `json:"address,omitempty"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
SourceName string `json:"sourceName"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
type AppointmentOption struct {
|
||||
ID string `json:"id"`
|
||||
DoctorID string `json:"doctorId"`
|
||||
Doctor string `json:"doctor"`
|
||||
Specialty string `json:"specialty"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
Clinic string `json:"clinic"`
|
||||
BookURL string `json:"bookUrl"`
|
||||
Remote bool `json:"remote"`
|
||||
}
|
||||
|
||||
type searxResponse struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Engine string `json:"engine"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func NewService(cfg ServiceConfig) *Service {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
return &Service{
|
||||
llm: cfg.LLM,
|
||||
searxngURL: strings.TrimSuffix(cfg.SearXNGURL, "/"),
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) StreamConsult(ctx context.Context, req ConsultRequest, writer io.Writer) error {
|
||||
writeEvent := func(eventType string, data any) {
|
||||
payload := map[string]any{"type": eventType}
|
||||
if data != nil {
|
||||
payload["data"] = data
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
_, _ = writer.Write(encoded)
|
||||
_, _ = writer.Write([]byte("\n"))
|
||||
if bw, ok := writer.(*bufio.Writer); ok {
|
||||
_ = bw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
writeBlock := func(blockID, blockType string, data any) {
|
||||
event := map[string]any{
|
||||
"type": "block",
|
||||
"block": map[string]any{
|
||||
"id": blockID,
|
||||
"type": blockType,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(event)
|
||||
_, _ = writer.Write(encoded)
|
||||
_, _ = writer.Write([]byte("\n"))
|
||||
if bw, ok := writer.(*bufio.Writer); ok {
|
||||
_ = bw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
writeEvent("messageStart", nil)
|
||||
|
||||
assessment, err := s.buildAssessment(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
city := strings.TrimSpace(req.City)
|
||||
if city == "" {
|
||||
city = "Москва"
|
||||
}
|
||||
doctors := s.searchDoctors(ctx, assessment.RecommendedSpecialists, city)
|
||||
bookingLinks := buildBookingLinks(doctors)
|
||||
|
||||
summary := buildSummaryText(req.Symptoms, city, assessment, doctors, bookingLinks)
|
||||
streamText(summary, writeEvent)
|
||||
|
||||
writeBlock(uuid.NewString(), "widget", map[string]any{
|
||||
"widgetType": "medicine_assessment",
|
||||
"params": map[string]any{
|
||||
"triageLevel": assessment.TriageLevel,
|
||||
"urgentSigns": assessment.UrgentSigns,
|
||||
"possibleConditions": assessment.PossibleConditions,
|
||||
"recommendedSpecialists": assessment.RecommendedSpecialists,
|
||||
"questionsToClarify": assessment.QuestionsToClarify,
|
||||
"homeCare": assessment.HomeCare,
|
||||
"disclaimer": assessment.Disclaimer,
|
||||
},
|
||||
})
|
||||
|
||||
writeBlock(uuid.NewString(), "widget", map[string]any{
|
||||
"widgetType": "medicine_doctors",
|
||||
"params": map[string]any{
|
||||
"city": city,
|
||||
"doctors": doctors,
|
||||
"specialists": assessment.RecommendedSpecialists,
|
||||
},
|
||||
})
|
||||
|
||||
writeBlock(uuid.NewString(), "widget", map[string]any{
|
||||
"widgetType": "medicine_appointments",
|
||||
"params": map[string]any{
|
||||
"bookingLinks": bookingLinks,
|
||||
},
|
||||
})
|
||||
|
||||
writeBlock(uuid.NewString(), "widget", map[string]any{
|
||||
"widgetType": "medicine_reference",
|
||||
"params": map[string]any{
|
||||
"medicationInfo": assessment.MedicationInfo,
|
||||
"supplementInfo": assessment.SupplementInfo,
|
||||
"procedureInfo": assessment.ProcedureInfo,
|
||||
"note": "Справочная информация. Назначения и схемы лечения определяет только врач после очного осмотра.",
|
||||
},
|
||||
})
|
||||
|
||||
writeEvent("messageEnd", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamText(text string, writeEvent func(string, any)) {
|
||||
chunks := splitTextByChunks(text, 120)
|
||||
for _, chunk := range chunks {
|
||||
writeEvent("textChunk", map[string]any{
|
||||
"chunk": chunk,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func splitTextByChunks(text string, size int) []string {
|
||||
if len(text) <= size {
|
||||
return []string{text}
|
||||
}
|
||||
parts := make([]string, 0, len(text)/size+1)
|
||||
runes := []rune(text)
|
||||
for i := 0; i < len(runes); i += size {
|
||||
end := i + size
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
parts = append(parts, string(runes[i:end]))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (s *Service) buildAssessment(ctx context.Context, req ConsultRequest) (*Assessment, error) {
|
||||
if s.llm == nil {
|
||||
return buildFallbackAssessment(req.Symptoms), nil
|
||||
}
|
||||
|
||||
historyContext := ""
|
||||
if len(req.History) > 0 {
|
||||
var hb strings.Builder
|
||||
hb.WriteString("\nИстория диалога:\n")
|
||||
for _, pair := range req.History {
|
||||
hb.WriteString(fmt.Sprintf("Пациент: %s\nВрач: %s\n", pair[0], pair[1]))
|
||||
}
|
||||
historyContext = hb.String()
|
||||
}
|
||||
|
||||
ageInfo := "не указан"
|
||||
if req.Age > 0 {
|
||||
ageInfo = fmt.Sprintf("%d", req.Age)
|
||||
}
|
||||
genderInfo := "не указан"
|
||||
if req.Gender != "" {
|
||||
genderInfo = req.Gender
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Ты опытный врач-терапевт, работающий в системе GooSeek. Веди себя как настоящий доктор на приёме.
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Дай ДИФФЕРЕНЦИАЛЬНУЮ оценку — перечисли вероятные состояния с обоснованием, от наиболее вероятного к менее.
|
||||
2. Для каждого состояния укажи likelihood (low/medium/high) и подробное "why" — почему именно эти симптомы указывают на это.
|
||||
3. Подбери конкретных специалистов с чёткой причиной направления.
|
||||
4. НЕ назначай таблетки, дозировки, схемы лечения. Только справочная информация: "для чего применяется" и "при каких состояниях назначают".
|
||||
5. Дай конкретные рекомендации по домашнему уходу до визита к врачу.
|
||||
6. Укажи красные флаги — при каких симптомах вызывать скорую немедленно.
|
||||
7. Задай уточняющие вопросы, которые помогут сузить диф-диагноз.
|
||||
|
||||
Симптомы пациента:
|
||||
%s
|
||||
%s
|
||||
Возраст: %s
|
||||
Пол: %s
|
||||
|
||||
Верни строго JSON (без markdown-обёрток):
|
||||
{
|
||||
"triageLevel": "low|medium|high|emergency",
|
||||
"urgentSigns": ["конкретный симптом при котором вызывать 103"],
|
||||
"possibleConditions": [{"name":"Название", "likelihood":"low|medium|high", "why":"Подробное обоснование на основе симптомов"}],
|
||||
"recommendedSpecialists": [{"specialty":"Название специальности", "reason":"Почему именно этот врач", "priority":"high|normal"}],
|
||||
"questionsToClarify": ["Конкретный вопрос пациенту"],
|
||||
"homeCare": ["Конкретная рекомендация что делать дома до визита"],
|
||||
"medicationInfo": [{"name":"Название", "forWhat":"При каких состояниях применяется", "notes":"Важные особенности"}],
|
||||
"supplementInfo": [{"name":"Название", "purpose":"Для чего", "evidence":"low|medium|high", "notes":"Примечания"}],
|
||||
"procedureInfo": [{"name":"Название обследования/процедуры", "purpose":"Что покажет/зачем", "whenUseful":"В каких случаях назначают"}],
|
||||
"disclaimer": "..."
|
||||
}`, req.Symptoms, historyContext, ageInfo, genderInfo)
|
||||
|
||||
resp, err := s.llm.GenerateText(ctx, llm.StreamRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: "Ты опытный врач-диагност. Отвечай на русском. Только валидный JSON. Никаких назначений лекарств и дозировок — только справочная информация."},
|
||||
{Role: llm.RoleUser, Content: prompt},
|
||||
},
|
||||
Options: llm.StreamOptions{
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 2800,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return buildFallbackAssessment(req.Symptoms), nil
|
||||
}
|
||||
|
||||
jsonBlock := extractJSONBlock(resp)
|
||||
if jsonBlock == "" {
|
||||
return buildFallbackAssessment(req.Symptoms), nil
|
||||
}
|
||||
|
||||
var result Assessment
|
||||
if err := json.Unmarshal([]byte(jsonBlock), &result); err != nil {
|
||||
return buildFallbackAssessment(req.Symptoms), nil
|
||||
}
|
||||
|
||||
normalizeAssessment(&result)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func normalizeAssessment(a *Assessment) {
|
||||
if a.TriageLevel == "" {
|
||||
a.TriageLevel = "medium"
|
||||
}
|
||||
if a.Disclaimer == "" {
|
||||
a.Disclaimer = "Информация носит справочный характер и не заменяет очный осмотр врача."
|
||||
}
|
||||
if len(a.RecommendedSpecialists) == 0 {
|
||||
a.RecommendedSpecialists = []SpecialtyItem{
|
||||
{Specialty: "Терапевт", Reason: "Первичный очный осмотр и маршрутизация", Priority: "high"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) searchDoctors(ctx context.Context, specialists []SpecialtyItem, city string) []DoctorOption {
|
||||
if s.searxngURL == "" {
|
||||
return fallbackDoctors(specialists, city)
|
||||
}
|
||||
|
||||
unique := make(map[string]struct{})
|
||||
out := make([]DoctorOption, 0, 9)
|
||||
|
||||
for _, sp := range specialists {
|
||||
if strings.TrimSpace(sp.Specialty) == "" {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("%s %s запись на прием", sp.Specialty, city)
|
||||
results, err := s.searchWeb(ctx, query)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range results {
|
||||
key := r.URL + "|" + sp.Specialty
|
||||
if _, ok := unique[key]; ok {
|
||||
continue
|
||||
}
|
||||
unique[key] = struct{}{}
|
||||
clinic := extractClinicName(r.Title)
|
||||
out = append(out, DoctorOption{
|
||||
ID: uuid.NewString(),
|
||||
Name: fmt.Sprintf("%s (%s)", sp.Specialty, clinic),
|
||||
Specialty: sp.Specialty,
|
||||
Clinic: clinic,
|
||||
City: city,
|
||||
SourceURL: r.URL,
|
||||
SourceName: sourceNameFromURL(r.URL),
|
||||
Snippet: trimText(r.Content, 220),
|
||||
})
|
||||
if len(out) >= 12 {
|
||||
sortDoctors(out)
|
||||
return out[:12]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return fallbackDoctors(specialists, city)
|
||||
}
|
||||
sortDoctors(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) searchWeb(ctx context.Context, query string) ([]struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
}, error) {
|
||||
values := url.Values{}
|
||||
values.Set("q", query)
|
||||
values.Set("format", "json")
|
||||
values.Set("language", "ru-RU")
|
||||
values.Set("safesearch", "1")
|
||||
|
||||
reqURL := s.searxngURL + "/search?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("search status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed searxResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
}, 0, len(parsed.Results))
|
||||
for _, r := range parsed.Results {
|
||||
if r.URL == "" || r.Title == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
}{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildFallbackAssessment(symptoms string) *Assessment {
|
||||
base := &Assessment{
|
||||
TriageLevel: "medium",
|
||||
UrgentSigns: []string{
|
||||
"резкая боль в груди", "затруднение дыхания", "потеря сознания", "кровотечение",
|
||||
},
|
||||
PossibleConditions: []ConditionItem{
|
||||
{Name: "ОРВИ/вирусная инфекция", Likelihood: "medium", Why: "Часто проявляется общими симптомами и слабостью"},
|
||||
{Name: "Воспалительный процесс", Likelihood: "low", Why: "Требует очной диагностики и анализа"},
|
||||
},
|
||||
RecommendedSpecialists: []SpecialtyItem{
|
||||
{Specialty: "Терапевт", Reason: "Первичный осмотр и назначение базовой диагностики", Priority: "high"},
|
||||
},
|
||||
QuestionsToClarify: []string{
|
||||
"Когда начались симптомы?",
|
||||
"Есть ли температура и как меняется в течение дня?",
|
||||
"Есть ли хронические заболевания и аллергии?",
|
||||
},
|
||||
HomeCare: []string{
|
||||
"Контролируйте температуру и самочувствие каждые 6-8 часов",
|
||||
"Поддерживайте питьевой режим",
|
||||
"При ухудшении состояния обращайтесь в неотложную помощь",
|
||||
},
|
||||
MedicationInfo: []MedicationInfo{
|
||||
{Name: "Парацетамол", ForWhat: "Снижение температуры и облегчение боли", Notes: "Только общая справка, дозировку определяет врач"},
|
||||
},
|
||||
SupplementInfo: []SupplementInfo{
|
||||
{Name: "Витамин D", Purpose: "Поддержка общего метаболизма", Evidence: "medium", Notes: "Эффективность зависит от дефицита по анализам"},
|
||||
},
|
||||
ProcedureInfo: []ProcedureInfo{
|
||||
{Name: "Общий анализ крови", Purpose: "Оценка воспалительного ответа", WhenUseful: "При сохраняющихся симптомах более 2-3 дней"},
|
||||
},
|
||||
Disclaimer: "Информация носит справочный характер и не заменяет консультацию врача.",
|
||||
}
|
||||
|
||||
lowered := strings.ToLower(symptoms)
|
||||
if strings.Contains(lowered, "груд") || strings.Contains(lowered, "дыш") || strings.Contains(lowered, "онем") {
|
||||
base.TriageLevel = "high"
|
||||
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
|
||||
SpecialtyItem{Specialty: "Кардиолог", Reason: "Исключение кардиологических причин", Priority: "high"},
|
||||
)
|
||||
}
|
||||
if strings.Contains(lowered, "живот") || strings.Contains(lowered, "тошн") {
|
||||
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
|
||||
SpecialtyItem{Specialty: "Гастроэнтеролог", Reason: "Оценка ЖКТ-симптомов", Priority: "normal"},
|
||||
)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func fallbackDoctors(specialists []SpecialtyItem, city string) []DoctorOption {
|
||||
if len(specialists) == 0 {
|
||||
specialists = []SpecialtyItem{{Specialty: "Терапевт"}}
|
||||
}
|
||||
out := make([]DoctorOption, 0, len(specialists))
|
||||
for i, sp := range specialists {
|
||||
out = append(out, DoctorOption{
|
||||
ID: uuid.NewString(),
|
||||
Name: fmt.Sprintf("%s, приём онлайн/очно", sp.Specialty),
|
||||
Specialty: sp.Specialty,
|
||||
Clinic: "Проверенные клиники",
|
||||
City: city,
|
||||
SourceURL: fmt.Sprintf("https://yandex.ru/search/?text=%s+%s+запись", url.QueryEscape(sp.Specialty), url.QueryEscape(city)),
|
||||
SourceName: "yandex",
|
||||
Snippet: "Подбор по агрегаторам клиник и медицинских центров.",
|
||||
})
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildBookingLinks(doctors []DoctorOption) []AppointmentOption {
|
||||
out := make([]AppointmentOption, 0, len(doctors))
|
||||
for _, d := range doctors {
|
||||
if d.SourceURL == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, AppointmentOption{
|
||||
ID: uuid.NewString(),
|
||||
DoctorID: d.ID,
|
||||
Doctor: d.Name,
|
||||
Specialty: d.Specialty,
|
||||
Clinic: d.Clinic,
|
||||
BookURL: d.SourceURL,
|
||||
Remote: strings.Contains(strings.ToLower(d.Snippet), "онлайн"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSummaryText(symptoms, city string, assessment *Assessment, doctors []DoctorOption, bookings []AppointmentOption) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("### Медицинская навигация\n\n")
|
||||
|
||||
triageEmoji := map[string]string{"low": "🟢", "medium": "🟡", "high": "🟠", "emergency": "🔴"}
|
||||
emoji := triageEmoji[assessment.TriageLevel]
|
||||
if emoji == "" {
|
||||
emoji = "🟡"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s **Приоритет: %s**\n\n", emoji, strings.ToUpper(assessment.TriageLevel)))
|
||||
|
||||
if assessment.TriageLevel == "emergency" || assessment.TriageLevel == "high" {
|
||||
b.WriteString("⚠️ **Рекомендуется срочное обращение к врачу.**\n\n")
|
||||
}
|
||||
|
||||
if len(assessment.PossibleConditions) > 0 {
|
||||
b.WriteString("**Вероятные состояния:**\n")
|
||||
for _, c := range assessment.PossibleConditions {
|
||||
likelihood := map[string]string{"low": "маловероятно", "medium": "возможно", "high": "вероятно"}
|
||||
lbl := likelihood[c.Likelihood]
|
||||
if lbl == "" {
|
||||
lbl = c.Likelihood
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- **%s** (%s) — %s\n", c.Name, lbl, c.Why))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(assessment.RecommendedSpecialists) > 0 {
|
||||
b.WriteString("**К кому обратиться:**\n")
|
||||
for _, sp := range assessment.RecommendedSpecialists {
|
||||
prio := ""
|
||||
if sp.Priority == "high" {
|
||||
prio = " ⚡"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- **%s**%s — %s\n", sp.Specialty, prio, sp.Reason))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(assessment.QuestionsToClarify) > 0 {
|
||||
b.WriteString("**Уточните для более точной оценки:**\n")
|
||||
for _, q := range assessment.QuestionsToClarify {
|
||||
b.WriteString(fmt.Sprintf("- %s\n", q))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(doctors) > 0 {
|
||||
b.WriteString(fmt.Sprintf("Найдено **%d** вариантов записи в городе **%s**. ", len(doctors), city))
|
||||
b.WriteString("Подробности — на панели справа.\n\n")
|
||||
}
|
||||
|
||||
if len(assessment.UrgentSigns) > 0 {
|
||||
b.WriteString("🚨 **При появлении:** ")
|
||||
b.WriteString(strings.Join(assessment.UrgentSigns[:min(3, len(assessment.UrgentSigns))], ", "))
|
||||
b.WriteString(" — **немедленно вызывайте 103/112.**\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("---\n")
|
||||
b.WriteString("*Информация носит справочный характер. Таблетки и схемы лечения не назначаются.*\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func extractJSONBlock(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if start := strings.Index(text, "```json"); start >= 0 {
|
||||
start += len("```json")
|
||||
if end := strings.Index(text[start:], "```"); end >= 0 {
|
||||
return strings.TrimSpace(text[start : start+end])
|
||||
}
|
||||
}
|
||||
if start := strings.Index(text, "{"); start >= 0 {
|
||||
if end := strings.LastIndex(text, "}"); end > start {
|
||||
return strings.TrimSpace(text[start : end+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractClinicName(title string) string {
|
||||
trimmed := strings.TrimSpace(title)
|
||||
if trimmed == "" {
|
||||
return "Клиника"
|
||||
}
|
||||
for _, sep := range []string{" - ", " | ", " — "} {
|
||||
if idx := strings.Index(trimmed, sep); idx > 0 {
|
||||
return strings.TrimSpace(trimmed[:idx])
|
||||
}
|
||||
}
|
||||
return trimText(trimmed, 56)
|
||||
}
|
||||
|
||||
func sourceNameFromURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "web"
|
||||
}
|
||||
host := strings.TrimPrefix(u.Hostname(), "www.")
|
||||
if host == "" {
|
||||
return "web"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func trimText(v string, max int) string {
|
||||
r := []rune(strings.TrimSpace(v))
|
||||
if len(r) <= max {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:max]) + "..."
|
||||
}
|
||||
|
||||
func sortDoctors(items []DoctorOption) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
a := strings.ToLower(items[i].SourceName)
|
||||
b := strings.ToLower(items[j].SourceName)
|
||||
if a == b {
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -628,6 +628,141 @@ func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteD
|
||||
return s.openRoute.GetDirections(ctx, points, "driving-car")
|
||||
}
|
||||
|
||||
// ValidateItineraryRequest is the input for itinerary validation.
|
||||
type ValidateItineraryRequest struct {
|
||||
Days []ValidateDay `json:"days"`
|
||||
POIs []ValidatePOI `json:"pois,omitempty"`
|
||||
Events []ValidateEvent `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type ValidateDay struct {
|
||||
Date string `json:"date"`
|
||||
Items []ValidateItem `json:"items"`
|
||||
}
|
||||
|
||||
type ValidateItem struct {
|
||||
RefType string `json:"refType"`
|
||||
RefID string `json:"refId"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type ValidatePOI struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Schedule map[string]string `json:"schedule,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
|
||||
type ValidateEvent struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
DateStart string `json:"dateStart,omitempty"`
|
||||
DateEnd string `json:"dateEnd,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
|
||||
type ValidationWarning struct {
|
||||
DayIdx int `json:"dayIdx"`
|
||||
ItemIdx int `json:"itemIdx,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ValidateItineraryResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Warnings []ValidationWarning `json:"warnings"`
|
||||
Suggestions []ValidationWarning `json:"suggestions"`
|
||||
}
|
||||
|
||||
func (s *Service) ValidateItinerary(ctx context.Context, req ValidateItineraryRequest) (*ValidateItineraryResponse, error) {
|
||||
if s.llmClient == nil {
|
||||
return &ValidateItineraryResponse{Valid: true, Warnings: []ValidationWarning{}, Suggestions: []ValidationWarning{}}, nil
|
||||
}
|
||||
|
||||
daysJSON, _ := json.Marshal(req.Days)
|
||||
poisJSON, _ := json.Marshal(req.POIs)
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
|
||||
prompt := fmt.Sprintf(`Проверь маршрут путешествия на логистические ошибки и предложи улучшения.
|
||||
|
||||
Маршрут по дням: %s
|
||||
|
||||
Доступные POI (с расписанием): %s
|
||||
|
||||
Доступные события (с датами): %s
|
||||
|
||||
Проверь:
|
||||
1. Логистику: нет ли точек в разных концах города подряд без достаточного времени на переезд
|
||||
2. Расписание: если POI имеет schedule и стоит в день когда закрыт — это ошибка
|
||||
3. Даты событий: если событие стоит в день вне его dateStart-dateEnd — это ошибка
|
||||
4. Реалистичность: не слишком ли много активностей в день (>6 основных)
|
||||
5. Время: нет ли пересечений по времени
|
||||
|
||||
Верни ТОЛЬКО JSON:
|
||||
{
|
||||
"valid": true/false,
|
||||
"warnings": [{"dayIdx": 0, "itemIdx": 2, "message": "причина"}],
|
||||
"suggestions": [{"dayIdx": 0, "message": "рекомендация"}]
|
||||
}
|
||||
|
||||
Если всё хорошо — warnings пустой массив, valid=true. Suggestions — необязательные рекомендации.`, string(daysJSON), string(poisJSON), string(eventsJSON))
|
||||
|
||||
var fullResponse strings.Builder
|
||||
err := s.llmClient.StreamChat(ctx, []ChatMessage{
|
||||
{Role: "user", Content: prompt},
|
||||
}, func(chunk string) {
|
||||
fullResponse.WriteString(chunk)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM validation failed: %w", err)
|
||||
}
|
||||
|
||||
responseText := fullResponse.String()
|
||||
|
||||
jsonStart := strings.Index(responseText, "{")
|
||||
jsonEnd := strings.LastIndex(responseText, "}")
|
||||
if jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart {
|
||||
return &ValidateItineraryResponse{
|
||||
Valid: false,
|
||||
Warnings: []ValidationWarning{{
|
||||
DayIdx: 0,
|
||||
Message: "Не удалось проверить маршрут — повторите попытку",
|
||||
}},
|
||||
Suggestions: []ValidationWarning{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result ValidateItineraryResponse
|
||||
if err := json.Unmarshal([]byte(responseText[jsonStart:jsonEnd+1]), &result); err != nil {
|
||||
return &ValidateItineraryResponse{
|
||||
Valid: false,
|
||||
Warnings: []ValidationWarning{{
|
||||
DayIdx: 0,
|
||||
Message: "Ошибка анализа маршрута — попробуйте ещё раз",
|
||||
}},
|
||||
Suggestions: []ValidationWarning{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.Warnings == nil {
|
||||
result.Warnings = []ValidationWarning{}
|
||||
}
|
||||
if result.Suggestions == nil {
|
||||
result.Suggestions = []ValidationWarning{}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error {
|
||||
if len(trip.Route) == 0 {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user