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:
203
backend/internal/learning/profile_builder.go
Normal file
203
backend/internal/learning/profile_builder.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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 "{}"
|
||||
}
|
||||
Reference in New Issue
Block a user