- 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
837 lines
28 KiB
Go
837 lines
28 KiB
Go
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
|
||
}
|