- 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
204 lines
5.8 KiB
Go
204 lines
5.8 KiB
Go
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 "{}"
|
||
}
|