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,671 @@
package medicine
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/gooseek/backend/internal/llm"
"github.com/google/uuid"
)
type ServiceConfig struct {
LLM llm.Client
SearXNGURL string
Timeout time.Duration
}
type Service struct {
llm llm.Client
searxngURL string
httpClient *http.Client
}
type ConsultRequest struct {
Symptoms string `json:"symptoms"`
City string `json:"city,omitempty"`
History [][2]string `json:"history,omitempty"`
Age int `json:"age,omitempty"`
Gender string `json:"gender,omitempty"`
ChatID string `json:"chatId,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
type ConditionItem struct {
Name string `json:"name"`
Likelihood string `json:"likelihood"`
Why string `json:"why"`
}
type SpecialtyItem struct {
Specialty string `json:"specialty"`
Reason string `json:"reason"`
Priority string `json:"priority"`
}
type MedicationInfo struct {
Name string `json:"name"`
ForWhat string `json:"forWhat"`
Notes string `json:"notes"`
}
type SupplementInfo struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
Evidence string `json:"evidence"`
Notes string `json:"notes"`
}
type ProcedureInfo struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
WhenUseful string `json:"whenUseful"`
}
type Assessment struct {
TriageLevel string `json:"triageLevel"`
UrgentSigns []string `json:"urgentSigns"`
PossibleConditions []ConditionItem `json:"possibleConditions"`
RecommendedSpecialists []SpecialtyItem `json:"recommendedSpecialists"`
QuestionsToClarify []string `json:"questionsToClarify"`
HomeCare []string `json:"homeCare"`
MedicationInfo []MedicationInfo `json:"medicationInfo"`
SupplementInfo []SupplementInfo `json:"supplementInfo"`
ProcedureInfo []ProcedureInfo `json:"procedureInfo"`
Disclaimer string `json:"disclaimer"`
}
type DoctorOption struct {
ID string `json:"id"`
Name string `json:"name"`
Specialty string `json:"specialty"`
Clinic string `json:"clinic"`
City string `json:"city"`
Address string `json:"address,omitempty"`
SourceURL string `json:"sourceUrl"`
SourceName string `json:"sourceName"`
Snippet string `json:"snippet,omitempty"`
}
type AppointmentOption struct {
ID string `json:"id"`
DoctorID string `json:"doctorId"`
Doctor string `json:"doctor"`
Specialty string `json:"specialty"`
StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"`
Clinic string `json:"clinic"`
BookURL string `json:"bookUrl"`
Remote bool `json:"remote"`
}
type searxResponse struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
} `json:"results"`
}
func NewService(cfg ServiceConfig) *Service {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
return &Service{
llm: cfg.LLM,
searxngURL: strings.TrimSuffix(cfg.SearXNGURL, "/"),
httpClient: &http.Client{Timeout: timeout},
}
}
func (s *Service) StreamConsult(ctx context.Context, req ConsultRequest, writer io.Writer) error {
writeEvent := func(eventType string, data any) {
payload := map[string]any{"type": eventType}
if data != nil {
payload["data"] = data
}
encoded, _ := json.Marshal(payload)
_, _ = writer.Write(encoded)
_, _ = writer.Write([]byte("\n"))
if bw, ok := writer.(*bufio.Writer); ok {
_ = bw.Flush()
}
}
writeBlock := func(blockID, blockType string, data any) {
event := map[string]any{
"type": "block",
"block": map[string]any{
"id": blockID,
"type": blockType,
"data": data,
},
}
encoded, _ := json.Marshal(event)
_, _ = writer.Write(encoded)
_, _ = writer.Write([]byte("\n"))
if bw, ok := writer.(*bufio.Writer); ok {
_ = bw.Flush()
}
}
writeEvent("messageStart", nil)
assessment, err := s.buildAssessment(ctx, req)
if err != nil {
return err
}
city := strings.TrimSpace(req.City)
if city == "" {
city = "Москва"
}
doctors := s.searchDoctors(ctx, assessment.RecommendedSpecialists, city)
bookingLinks := buildBookingLinks(doctors)
summary := buildSummaryText(req.Symptoms, city, assessment, doctors, bookingLinks)
streamText(summary, writeEvent)
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_assessment",
"params": map[string]any{
"triageLevel": assessment.TriageLevel,
"urgentSigns": assessment.UrgentSigns,
"possibleConditions": assessment.PossibleConditions,
"recommendedSpecialists": assessment.RecommendedSpecialists,
"questionsToClarify": assessment.QuestionsToClarify,
"homeCare": assessment.HomeCare,
"disclaimer": assessment.Disclaimer,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_doctors",
"params": map[string]any{
"city": city,
"doctors": doctors,
"specialists": assessment.RecommendedSpecialists,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_appointments",
"params": map[string]any{
"bookingLinks": bookingLinks,
},
})
writeBlock(uuid.NewString(), "widget", map[string]any{
"widgetType": "medicine_reference",
"params": map[string]any{
"medicationInfo": assessment.MedicationInfo,
"supplementInfo": assessment.SupplementInfo,
"procedureInfo": assessment.ProcedureInfo,
"note": "Справочная информация. Назначения и схемы лечения определяет только врач после очного осмотра.",
},
})
writeEvent("messageEnd", nil)
return nil
}
func streamText(text string, writeEvent func(string, any)) {
chunks := splitTextByChunks(text, 120)
for _, chunk := range chunks {
writeEvent("textChunk", map[string]any{
"chunk": chunk,
})
}
}
func splitTextByChunks(text string, size int) []string {
if len(text) <= size {
return []string{text}
}
parts := make([]string, 0, len(text)/size+1)
runes := []rune(text)
for i := 0; i < len(runes); i += size {
end := i + size
if end > len(runes) {
end = len(runes)
}
parts = append(parts, string(runes[i:end]))
}
return parts
}
func (s *Service) buildAssessment(ctx context.Context, req ConsultRequest) (*Assessment, error) {
if s.llm == nil {
return buildFallbackAssessment(req.Symptoms), nil
}
historyContext := ""
if len(req.History) > 0 {
var hb strings.Builder
hb.WriteString("\nИстория диалога:\n")
for _, pair := range req.History {
hb.WriteString(fmt.Sprintf("Пациент: %s\nВрач: %s\n", pair[0], pair[1]))
}
historyContext = hb.String()
}
ageInfo := "не указан"
if req.Age > 0 {
ageInfo = fmt.Sprintf("%d", req.Age)
}
genderInfo := "не указан"
if req.Gender != "" {
genderInfo = req.Gender
}
prompt := fmt.Sprintf(`Ты опытный врач-терапевт, работающий в системе GooSeek. Веди себя как настоящий доктор на приёме.
ПРАВИЛА:
1. Дай ДИФФЕРЕНЦИАЛЬНУЮ оценку — перечисли вероятные состояния с обоснованием, от наиболее вероятного к менее.
2. Для каждого состояния укажи likelihood (low/medium/high) и подробное "why" — почему именно эти симптомы указывают на это.
3. Подбери конкретных специалистов с чёткой причиной направления.
4. НЕ назначай таблетки, дозировки, схемы лечения. Только справочная информация: "для чего применяется" и "при каких состояниях назначают".
5. Дай конкретные рекомендации по домашнему уходу до визита к врачу.
6. Укажи красные флаги — при каких симптомах вызывать скорую немедленно.
7. Задай уточняющие вопросы, которые помогут сузить диф-диагноз.
Симптомы пациента:
%s
%s
Возраст: %s
Пол: %s
Верни строго JSON (без markdown-обёрток):
{
"triageLevel": "low|medium|high|emergency",
"urgentSigns": ["конкретный симптом при котором вызывать 103"],
"possibleConditions": [{"name":"Название", "likelihood":"low|medium|high", "why":"Подробное обоснование на основе симптомов"}],
"recommendedSpecialists": [{"specialty":"Название специальности", "reason":"Почему именно этот врач", "priority":"high|normal"}],
"questionsToClarify": ["Конкретный вопрос пациенту"],
"homeCare": ["Конкретная рекомендация что делать дома до визита"],
"medicationInfo": [{"name":"Название", "forWhat":"При каких состояниях применяется", "notes":"Важные особенности"}],
"supplementInfo": [{"name":"Название", "purpose":"Для чего", "evidence":"low|medium|high", "notes":"Примечания"}],
"procedureInfo": [{"name":"Название обследования/процедуры", "purpose":"Что покажет/зачем", "whenUseful":"В каких случаях назначают"}],
"disclaimer": "..."
}`, req.Symptoms, historyContext, ageInfo, genderInfo)
resp, err := s.llm.GenerateText(ctx, llm.StreamRequest{
Messages: []llm.Message{
{Role: llm.RoleSystem, Content: "Ты опытный врач-диагност. Отвечай на русском. Только валидный JSON. Никаких назначений лекарств и дозировок — только справочная информация."},
{Role: llm.RoleUser, Content: prompt},
},
Options: llm.StreamOptions{
Temperature: 0.3,
MaxTokens: 2800,
},
})
if err != nil {
return buildFallbackAssessment(req.Symptoms), nil
}
jsonBlock := extractJSONBlock(resp)
if jsonBlock == "" {
return buildFallbackAssessment(req.Symptoms), nil
}
var result Assessment
if err := json.Unmarshal([]byte(jsonBlock), &result); err != nil {
return buildFallbackAssessment(req.Symptoms), nil
}
normalizeAssessment(&result)
return &result, nil
}
func normalizeAssessment(a *Assessment) {
if a.TriageLevel == "" {
a.TriageLevel = "medium"
}
if a.Disclaimer == "" {
a.Disclaimer = "Информация носит справочный характер и не заменяет очный осмотр врача."
}
if len(a.RecommendedSpecialists) == 0 {
a.RecommendedSpecialists = []SpecialtyItem{
{Specialty: "Терапевт", Reason: "Первичный очный осмотр и маршрутизация", Priority: "high"},
}
}
}
func (s *Service) searchDoctors(ctx context.Context, specialists []SpecialtyItem, city string) []DoctorOption {
if s.searxngURL == "" {
return fallbackDoctors(specialists, city)
}
unique := make(map[string]struct{})
out := make([]DoctorOption, 0, 9)
for _, sp := range specialists {
if strings.TrimSpace(sp.Specialty) == "" {
continue
}
query := fmt.Sprintf("%s %s запись на прием", sp.Specialty, city)
results, err := s.searchWeb(ctx, query)
if err != nil {
continue
}
for _, r := range results {
key := r.URL + "|" + sp.Specialty
if _, ok := unique[key]; ok {
continue
}
unique[key] = struct{}{}
clinic := extractClinicName(r.Title)
out = append(out, DoctorOption{
ID: uuid.NewString(),
Name: fmt.Sprintf("%s (%s)", sp.Specialty, clinic),
Specialty: sp.Specialty,
Clinic: clinic,
City: city,
SourceURL: r.URL,
SourceName: sourceNameFromURL(r.URL),
Snippet: trimText(r.Content, 220),
})
if len(out) >= 12 {
sortDoctors(out)
return out[:12]
}
}
}
if len(out) == 0 {
return fallbackDoctors(specialists, city)
}
sortDoctors(out)
return out
}
func (s *Service) searchWeb(ctx context.Context, query string) ([]struct {
Title string
URL string
Content string
}, error) {
values := url.Values{}
values.Set("q", query)
values.Set("format", "json")
values.Set("language", "ru-RU")
values.Set("safesearch", "1")
reqURL := s.searxngURL + "/search?" + values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("search status %d", resp.StatusCode)
}
var parsed searxResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, err
}
items := make([]struct {
Title string
URL string
Content string
}, 0, len(parsed.Results))
for _, r := range parsed.Results {
if r.URL == "" || r.Title == "" {
continue
}
items = append(items, struct {
Title string
URL string
Content string
}{
Title: r.Title,
URL: r.URL,
Content: r.Content,
})
if len(items) >= 5 {
break
}
}
return items, nil
}
func buildFallbackAssessment(symptoms string) *Assessment {
base := &Assessment{
TriageLevel: "medium",
UrgentSigns: []string{
"резкая боль в груди", "затруднение дыхания", "потеря сознания", "кровотечение",
},
PossibleConditions: []ConditionItem{
{Name: "ОРВИ/вирусная инфекция", Likelihood: "medium", Why: "Часто проявляется общими симптомами и слабостью"},
{Name: "Воспалительный процесс", Likelihood: "low", Why: "Требует очной диагностики и анализа"},
},
RecommendedSpecialists: []SpecialtyItem{
{Specialty: "Терапевт", Reason: "Первичный осмотр и назначение базовой диагностики", Priority: "high"},
},
QuestionsToClarify: []string{
"Когда начались симптомы?",
"Есть ли температура и как меняется в течение дня?",
"Есть ли хронические заболевания и аллергии?",
},
HomeCare: []string{
"Контролируйте температуру и самочувствие каждые 6-8 часов",
"Поддерживайте питьевой режим",
"При ухудшении состояния обращайтесь в неотложную помощь",
},
MedicationInfo: []MedicationInfo{
{Name: "Парацетамол", ForWhat: "Снижение температуры и облегчение боли", Notes: "Только общая справка, дозировку определяет врач"},
},
SupplementInfo: []SupplementInfo{
{Name: "Витамин D", Purpose: "Поддержка общего метаболизма", Evidence: "medium", Notes: "Эффективность зависит от дефицита по анализам"},
},
ProcedureInfo: []ProcedureInfo{
{Name: "Общий анализ крови", Purpose: "Оценка воспалительного ответа", WhenUseful: "При сохраняющихся симптомах более 2-3 дней"},
},
Disclaimer: "Информация носит справочный характер и не заменяет консультацию врача.",
}
lowered := strings.ToLower(symptoms)
if strings.Contains(lowered, "груд") || strings.Contains(lowered, "дыш") || strings.Contains(lowered, "онем") {
base.TriageLevel = "high"
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
SpecialtyItem{Specialty: "Кардиолог", Reason: "Исключение кардиологических причин", Priority: "high"},
)
}
if strings.Contains(lowered, "живот") || strings.Contains(lowered, "тошн") {
base.RecommendedSpecialists = append(base.RecommendedSpecialists,
SpecialtyItem{Specialty: "Гастроэнтеролог", Reason: "Оценка ЖКТ-симптомов", Priority: "normal"},
)
}
return base
}
func fallbackDoctors(specialists []SpecialtyItem, city string) []DoctorOption {
if len(specialists) == 0 {
specialists = []SpecialtyItem{{Specialty: "Терапевт"}}
}
out := make([]DoctorOption, 0, len(specialists))
for i, sp := range specialists {
out = append(out, DoctorOption{
ID: uuid.NewString(),
Name: fmt.Sprintf("%s, приём онлайн/очно", sp.Specialty),
Specialty: sp.Specialty,
Clinic: "Проверенные клиники",
City: city,
SourceURL: fmt.Sprintf("https://yandex.ru/search/?text=%s+%s+запись", url.QueryEscape(sp.Specialty), url.QueryEscape(city)),
SourceName: "yandex",
Snippet: "Подбор по агрегаторам клиник и медицинских центров.",
})
if i >= 5 {
break
}
}
return out
}
func buildBookingLinks(doctors []DoctorOption) []AppointmentOption {
out := make([]AppointmentOption, 0, len(doctors))
for _, d := range doctors {
if d.SourceURL == "" {
continue
}
out = append(out, AppointmentOption{
ID: uuid.NewString(),
DoctorID: d.ID,
Doctor: d.Name,
Specialty: d.Specialty,
Clinic: d.Clinic,
BookURL: d.SourceURL,
Remote: strings.Contains(strings.ToLower(d.Snippet), "онлайн"),
})
}
return out
}
func buildSummaryText(symptoms, city string, assessment *Assessment, doctors []DoctorOption, bookings []AppointmentOption) string {
var b strings.Builder
b.WriteString("### Медицинская навигация\n\n")
triageEmoji := map[string]string{"low": "🟢", "medium": "🟡", "high": "🟠", "emergency": "🔴"}
emoji := triageEmoji[assessment.TriageLevel]
if emoji == "" {
emoji = "🟡"
}
b.WriteString(fmt.Sprintf("%s **Приоритет: %s**\n\n", emoji, strings.ToUpper(assessment.TriageLevel)))
if assessment.TriageLevel == "emergency" || assessment.TriageLevel == "high" {
b.WriteString("⚠️ **Рекомендуется срочное обращение к врачу.**\n\n")
}
if len(assessment.PossibleConditions) > 0 {
b.WriteString("**Вероятные состояния:**\n")
for _, c := range assessment.PossibleConditions {
likelihood := map[string]string{"low": "маловероятно", "medium": "возможно", "high": "вероятно"}
lbl := likelihood[c.Likelihood]
if lbl == "" {
lbl = c.Likelihood
}
b.WriteString(fmt.Sprintf("- **%s** (%s) — %s\n", c.Name, lbl, c.Why))
}
b.WriteString("\n")
}
if len(assessment.RecommendedSpecialists) > 0 {
b.WriteString("**К кому обратиться:**\n")
for _, sp := range assessment.RecommendedSpecialists {
prio := ""
if sp.Priority == "high" {
prio = " ⚡"
}
b.WriteString(fmt.Sprintf("- **%s**%s — %s\n", sp.Specialty, prio, sp.Reason))
}
b.WriteString("\n")
}
if len(assessment.QuestionsToClarify) > 0 {
b.WriteString("**Уточните для более точной оценки:**\n")
for _, q := range assessment.QuestionsToClarify {
b.WriteString(fmt.Sprintf("- %s\n", q))
}
b.WriteString("\n")
}
if len(doctors) > 0 {
b.WriteString(fmt.Sprintf("Найдено **%d** вариантов записи в городе **%s**. ", len(doctors), city))
b.WriteString("Подробности — на панели справа.\n\n")
}
if len(assessment.UrgentSigns) > 0 {
b.WriteString("🚨 **При появлении:** ")
b.WriteString(strings.Join(assessment.UrgentSigns[:min(3, len(assessment.UrgentSigns))], ", "))
b.WriteString(" — **немедленно вызывайте 103/112.**\n\n")
}
b.WriteString("---\n")
b.WriteString("*Информация носит справочный характер. Таблетки и схемы лечения не назначаются.*\n")
return b.String()
}
func extractJSONBlock(text string) string {
if text == "" {
return ""
}
if start := strings.Index(text, "```json"); start >= 0 {
start += len("```json")
if end := strings.Index(text[start:], "```"); end >= 0 {
return strings.TrimSpace(text[start : start+end])
}
}
if start := strings.Index(text, "{"); start >= 0 {
if end := strings.LastIndex(text, "}"); end > start {
return strings.TrimSpace(text[start : end+1])
}
}
return ""
}
func extractClinicName(title string) string {
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return "Клиника"
}
for _, sep := range []string{" - ", " | ", " — "} {
if idx := strings.Index(trimmed, sep); idx > 0 {
return strings.TrimSpace(trimmed[:idx])
}
}
return trimText(trimmed, 56)
}
func sourceNameFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "web"
}
host := strings.TrimPrefix(u.Hostname(), "www.")
if host == "" {
return "web"
}
return host
}
func trimText(v string, max int) string {
r := []rune(strings.TrimSpace(v))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "..."
}
func sortDoctors(items []DoctorOption) {
sort.SliceStable(items, func(i, j int) bool {
a := strings.ToLower(items[i].SourceName)
b := strings.ToLower(items[j].SourceName)
if a == b {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
}
return a < b
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}