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 }