Files
home ab48a0632b
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
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
2026-03-02 20:25:44 +03:00

672 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}