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 }