Files
gooseek/backend/internal/agent/learning_orchestrator.go
home ab48a0632b
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped
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
2026-03-02 20:25:44 +03:00

837 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}