- 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
672 lines
22 KiB
Go
672 lines
22 KiB
Go
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
|
||
}
|