package learning import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/gooseek/backend/internal/llm" ) type UserProfile struct { Name string `json:"name"` ExperienceYears float64 `json:"experience_years"` CurrentRole string `json:"current_role"` Skills []string `json:"skills"` ProgrammingLangs []string `json:"programming_languages"` Frameworks []string `json:"frameworks"` Education string `json:"education"` Industries []string `json:"industries"` Strengths []string `json:"strengths"` GrowthAreas []string `json:"growth_areas"` CareerGoals string `json:"career_goals"` RecommendedTracks []string `json:"recommended_tracks"` Level string `json:"level"` Summary string `json:"summary"` } func BuildProfileFromResume(ctx context.Context, llmClient llm.Client, extractedText string) (json.RawMessage, error) { if strings.TrimSpace(extractedText) == "" { return json.RawMessage("{}"), fmt.Errorf("empty resume text") } if len(extractedText) > 12000 { extractedText = extractedText[:12000] } prompt := `Ты — senior HR-аналитик с 15-летним опытом в IT-рекрутинге в РФ. Проанализируй резюме и создай детальный профиль. Резюме: ` + extractedText + ` ЗАДАЧА: Извлеки максимум информации. Определи реальный уровень кандидата (не завышай). Ответь строго JSON (без markdown, без комментариев): { "name": "Имя Фамилия", "experience_years": 3.5, "current_role": "текущая должность или последняя", "skills": ["навык1", "навык2", "навык3"], "programming_languages": ["Go", "Python"], "frameworks": ["React", "Fiber"], "education": "образование кратко", "industries": ["fintech", "ecommerce"], "strengths": ["сильная сторона 1", "сильная сторона 2"], "growth_areas": ["зона роста 1", "зона роста 2"], "career_goals": "предположительные цели на основе опыта", "recommended_tracks": ["рекомендуемый трек 1", "трек 2"], "level": "junior|middle|senior|lead|expert", "summary": "Краткая характеристика кандидата в 2-3 предложения" }` var profile UserProfile err := generateAndParse(ctx, llmClient, prompt, &profile, 2) if err != nil { return json.RawMessage("{}"), fmt.Errorf("profile extraction failed: %w", err) } if profile.Level == "" { profile.Level = inferLevel(profile.ExperienceYears) } if profile.Summary == "" { profile.Summary = fmt.Sprintf("%s, %s, опыт %.0f лет", profile.Name, profile.CurrentRole, profile.ExperienceYears) } result, err := json.Marshal(profile) if err != nil { return json.RawMessage("{}"), err } return result, nil } func BuildProfileFromOnboarding(ctx context.Context, llmClient llm.Client, answers map[string]string) (json.RawMessage, error) { answersJSON, _ := json.Marshal(answers) prompt := `Ты — методолог обучения. На основе ответов пользователя на онбординг-вопросы, построй профиль. Ответы пользователя: ` + string(answersJSON) + ` Ответь строго JSON: { "name": "", "experience_years": 0, "current_role": "", "skills": [], "programming_languages": [], "frameworks": [], "education": "", "industries": [], "strengths": [], "growth_areas": [], "career_goals": "", "recommended_tracks": [], "level": "beginner|junior|middle|senior", "summary": "Краткая характеристика" }` var profile UserProfile if err := generateAndParse(ctx, llmClient, prompt, &profile, 2); err != nil { return json.RawMessage("{}"), err } if profile.Level == "" { profile.Level = "beginner" } result, _ := json.Marshal(profile) return result, nil } func inferLevel(years float64) string { switch { case years < 1: return "beginner" case years < 3: return "junior" case years < 5: return "middle" case years < 8: return "senior" default: return "lead" } } func generateAndParse(ctx context.Context, llmClient llm.Client, prompt string, target interface{}, maxRetries int) error { var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second) result, err := llmClient.GenerateText(attemptCtx, llm.StreamRequest{ Messages: []llm.Message{{Role: "user", Content: prompt}}, }) cancel() if err != nil { lastErr = err continue } jsonStr := extractJSONBlock(result) if err := json.Unmarshal([]byte(jsonStr), target); err != nil { lastErr = fmt.Errorf("attempt %d: JSON parse error: %w", attempt, err) continue } return nil } return fmt.Errorf("all %d attempts failed: %w", maxRetries+1, lastErr) } func extractJSONBlock(text string) string { if strings.Contains(text, "```json") { start := strings.Index(text, "```json") + 7 end := strings.Index(text[start:], "```") if end > 0 { return strings.TrimSpace(text[start : start+end]) } } if strings.Contains(text, "```") { start := strings.Index(text, "```") + 3 if nl := strings.Index(text[start:], "\n"); nl >= 0 { start += nl + 1 } end := strings.Index(text[start:], "```") if end > 0 { candidate := strings.TrimSpace(text[start : start+end]) if len(candidate) > 2 && candidate[0] == '{' { return candidate } } } depth := 0 startIdx := -1 for i, ch := range text { if ch == '{' { if depth == 0 { startIdx = i } depth++ } else if ch == '}' { depth-- if depth == 0 && startIdx >= 0 { return text[startIdx : i+1] } } } return "{}" }