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
|
||||
}
|
||||
Reference in New Issue
Block a user