feat: CI/CD pipeline + Learning/Medicine/Travel services
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped

- 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:
home
2026-03-02 20:25:44 +03:00
parent 08bd41e75c
commit ab48a0632b
92 changed files with 15562 additions and 2198 deletions

View 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 "{}"
}