From 124871da76f1ca1a2bd8da1578e6e67c2ee8cacd Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 25 Aug 2026 18:02:59 +0300 Subject: [PATCH 1/4] Cancel only the owned ComfyUI prompt --- adapters/multimodal/client.go | 39 +++++++++++++++++++++++++++++ adapters/multimodal/client_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/adapters/multimodal/client.go b/adapters/multimodal/client.go index d187a74..e35227a 100644 --- a/adapters/multimodal/client.go +++ b/adapters/multimodal/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log" "mime/multipart" "net/http" "net/textproto" @@ -275,6 +276,20 @@ func (c ComfyImageGenerationClient) Generate(ctx context.Context, model, prompt, if accepted.PromptID == "" { return GeneratedImage{}, ErrEmptyResult } + // A caller timeout must remove only this prompt. ComfyUI's global + // /interrupt endpoint can otherwise stop unrelated users' work and leave + // queued requests in an ambiguous state. + defer func() { + if ctx.Err() != nil { + cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.cancelPrompt(cancelCtx, accepted.PromptID); err != nil { + // The original context error is more useful to the caller; the + // provider cleanup failure remains observable in the server log. + log.Printf("comfy prompt cleanup failed for %s: %v", accepted.PromptID, err) + } + } + }() interval := c.PollInterval if interval <= 0 { @@ -306,6 +321,30 @@ func (c ComfyImageGenerationClient) Generate(ctx context.Context, model, prompt, } } +func (c ComfyImageGenerationClient) cancelPrompt(ctx context.Context, promptID string) error { + if strings.TrimSpace(promptID) == "" { + return errors.New("comfy prompt id is empty") + } + payload, err := json.Marshal(map[string][]string{"delete": {promptID}}) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/queue", bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := c.client().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode > 299 { + return fmt.Errorf("comfy prompt cleanup status %d", response.StatusCode) + } + return nil +} + func (c ComfyImageGenerationClient) historyImage(ctx context.Context, promptID string) (GeneratedImage, bool, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+"/history/"+url.PathEscape(promptID), nil) if err != nil { diff --git a/adapters/multimodal/client_test.go b/adapters/multimodal/client_test.go index 6f1d981..e4ddf49 100644 --- a/adapters/multimodal/client_test.go +++ b/adapters/multimodal/client_test.go @@ -3,6 +3,7 @@ package multimodal import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -167,3 +168,42 @@ func TestComfyImageGenerationClientRunsWorkflowAndReturnsImage(t *testing.T) { t.Fatalf("image=%+v err=%v", image, err) } } + +func TestComfyImageGenerationClientDeletesOnlyCancelledPrompt(t *testing.T) { + var deleted []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/prompt": + _ = json.NewEncoder(w).Encode(map[string]string{"prompt_id": "cancel-me"}) + case "/history/cancel-me": + <-r.Context().Done() + case "/queue": + var payload struct { + Delete []string `json:"delete"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + deleted = append(deleted, payload.Delete...) + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := (ComfyImageGenerationClient{BaseURL: server.URL, Client: server.Client(), PollInterval: time.Millisecond, PollTimeout: time.Minute}).Generate(ctx, "", "red bird", "256x256") + done <- err + }() + time.Sleep(10 * time.Millisecond) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } + if len(deleted) != 1 || deleted[0] != "cancel-me" { + t.Fatalf("deleted=%v", deleted) + } +} -- 2.54.0 From f121445f24195f9c705e11110ecd84f805f71069 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 25 Aug 2026 18:08:48 +0300 Subject: [PATCH 2/4] Preserve media failures and enrich nearest flight searches --- cmd/core-runtime/main.go | 11 +++++--- modules/interaction/domain/semantics_test.go | 10 +++++++ modules/interaction/domain/travel.go | 29 +++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 3bf0976..c72df82 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -771,10 +771,11 @@ func main() { replies = append(replies, "По запросу «"+part+"»: "+clarification) continue } + searchQuery := interactiondomain.TravelSearchQuery(part, now) researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second) result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{ - Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: part, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, - PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: part, + Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: searchQuery, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, + PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: searchQuery, }) cancelResearch() reply, backed := renderResearchReply(part, result, researchErr) @@ -821,7 +822,9 @@ func main() { var reminder *continuity.ProspectiveItem var generatedImage capability.MultimodalResponse mediaHandled := false + mediaAttempted := false if hasChatRoute(routePlan, chatRouteMedia) && semantic.Interpretation.Clarification != interaction.ClarificationRequired { + mediaAttempted = true imageCtx, cancelImage := context.WithTimeout(r.Context(), 15*time.Minute) generatedImage, err = cognitiveClient.GenerateImage(imageCtx, effectiveTaskMessage, "") cancelImage() @@ -920,7 +923,7 @@ func main() { ConfidenceMillis: semantic.Interpretation.ConfidenceMillis, }) cancelReview() - stableSemanticReply := mediaHandled || identityReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection + stableSemanticReply := mediaAttempted || identityReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection if reviewErr != nil { log.Printf("cognitive response review unavailable: %v", reviewErr) } else if !stableSemanticReply && (!researchBacked || reviewPreservesResearchEvidence(reviewedReply, planningReply)) { @@ -929,7 +932,7 @@ func main() { log.Printf("cognitive response review proposed stable semantic rewrite; preserving semantic reply") } } - if !mediaHandled && identityReply == "" && creativeReply == "" && creativeFallbackReply == "" { + if !mediaAttempted && !mediaHandled && identityReply == "" && creativeReply == "" && creativeFallbackReply == "" { stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID) travelClarification := "" if !creativeRequest { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index ae1162e..aa4eb65 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -149,6 +149,16 @@ func TestTravelSearchClarification(t *testing.T) { } } +func TestTravelSearchQueryCarriesNearestFlightDate(t *testing.T) { + now := time.Date(2026, time.August, 25, 18, 0, 0, 0, time.UTC) + if got := TravelSearchQuery("какой ближайший рейс из Москвы в Казань", now); got != "какой ближайший рейс из Москвы в Казань сегодня 25.08.2026" { + t.Fatalf("nearest flight query=%q", got) + } + if got := TravelSearchQuery("рейс из Москвы в Казань завтра", now); got != "рейс из Москвы в Казань завтра" { + t.Fatalf("explicit date was changed: %q", got) + } +} + func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) { result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "") if result.Interpretation.SpeechAct != interaction.SpeechActDirective { diff --git a/modules/interaction/domain/travel.go b/modules/interaction/domain/travel.go index fe3b8a1..05a9725 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -2,10 +2,25 @@ package domain import ( "strings" + "time" "git.cin.su/m/Assistant/contracts/interaction" ) +// TravelSearchQuery carries the implicit current-day meaning of a nearest +// flight request into the research adapter. +func TravelSearchQuery(message string, now time.Time) string { + cleaned := strings.TrimSpace(StripAssistantVocative(message)) + tokens := semanticTokens(cleaned) + if !travelCue(tokens) || !containsStemInTokens(tokens, "ближ") || isTravelDateSpecified(tokens) { + return cleaned + } + if now.IsZero() { + return cleaned + " сегодня" + } + return cleaned + " сегодня " + now.Format("02.01.2006") +} + // StripAssistantVocative removes a leading assistant name used as an address. // The original utterance remains the historical source; this value is only for // interpretation, cognitive prompts and external queries. @@ -159,7 +174,7 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera } origin := containsStemInTokens(current, "из", "откуда", "от") destination := containsStemInTokens(current, "в", "куда", "до") - date := containsStemInTokens(current, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр") + date := isTravelDateSpecified(current) if containsStemInTokens(current, "ближ") { date = true } @@ -209,6 +224,18 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera return "Уточните маршрут и дату: откуда, куда и на какой день нужны билеты?" } +func isTravelDateSpecified(tokens []string) bool { + if containsStemInTokens(tokens, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр") { + return true + } + for _, token := range tokens { + if hasDigit(token) { + return true + } + } + return false +} + func isTravelDateWord(value string) bool { return containsStem(value, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр") } -- 2.54.0 From d342728b46352e2178d23fc73380e2c2c9adc128 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 25 Aug 2026 18:12:13 +0300 Subject: [PATCH 3/4] Improve nearest flight research answers --- adapters/research/search_connector.go | 3 +++ adapters/research/search_connector_test.go | 6 ++++++ cmd/core-runtime/main.go | 12 ++++++++++++ cmd/core-runtime/main_test.go | 12 ++++++++++++ 4 files changed, 33 insertions(+) diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go index d27a73f..811b524 100644 --- a/adapters/research/search_connector.go +++ b/adapters/research/search_connector.go @@ -211,6 +211,9 @@ func enrichedSearchQuery(query string) string { if (strings.Contains(lower, "цен") || strings.Contains(lower, "стоим")) && !strings.Contains(lower, "куп") { return trimmed + " цена купить сегодня" } + if (strings.Contains(lower, "рейс") || strings.Contains(lower, "самолет") || strings.Contains(lower, "самолёт") || strings.Contains(lower, "вылет")) && strings.Contains(lower, "ближ") { + return trimmed + " расписание вылеты время" + } return trimmed } diff --git a/adapters/research/search_connector_test.go b/adapters/research/search_connector_test.go index 3011b89..0f176b1 100644 --- a/adapters/research/search_connector_test.go +++ b/adapters/research/search_connector_test.go @@ -55,3 +55,9 @@ func TestEnrichedSearchQueryAddsPriceSignalsOnlyToPriceRequests(t *testing.T) { t.Fatalf("non-price query = %q", got) } } + +func TestEnrichedSearchQueryAddsFlightSignalsToNearestFlightRequests(t *testing.T) { + if got := enrichedSearchQuery("ближайший рейс Москва Казань сегодня 25.08.2026"); got != "ближайший рейс Москва Казань сегодня 25.08.2026 расписание вылеты время" { + t.Fatalf("flight query = %q", got) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index c72df82..f83f552 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -1722,6 +1722,9 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc if len(lines) == 0 { return "Проверяемых данных пока не найдено. Могу повторить поиск или уточнить, что именно проверить.", false } + if nearestFlightQuery(query) && !containsFlightTimeEvidence(strings.Join(lines, " ")) { + return "Нашёл расписание по маршруту, но точное время ближайшего вылета в доступных фрагментах не указано. Откройте источник и выберите ближайший рейс:\n\n" + strings.Join(lines, "\n\n"), true + } reply := "Нашёл проверяемые данные:\n\n" + strings.Join(lines, "\n\n") lowerQuery := strings.ToLower(query) if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) { @@ -1730,6 +1733,15 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc return reply, true } +func nearestFlightQuery(query string) bool { + lower := strings.ToLower(query) + return strings.Contains(lower, "ближ") && (strings.Contains(lower, "рейс") || strings.Contains(lower, "самолет") || strings.Contains(lower, "самолёт") || strings.Contains(lower, "вылет")) +} + +func containsFlightTimeEvidence(value string) bool { + return regexp.MustCompile(`(?m)\b(?:[01]?\d|2[0-3]):[0-5]\d\b`).MatchString(value) +} + func humanResearchExcerpt(query, raw string) string { text := strings.Join(strings.Fields(strings.TrimSpace(raw)), " ") if text == "" { diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index c329620..a9ac525 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -22,6 +22,18 @@ func TestHumanResearchExcerptRemovesSearchResultHeading(t *testing.T) { } } +func TestNearestFlightEvidenceDoesNotClaimMissingDepartureTime(t *testing.T) { + if !nearestFlightQuery("ближайший рейс Москва Казань сегодня") { + t.Fatal("nearest flight query was not recognized") + } + if containsFlightTimeEvidence("Казань составляет 1 ч 25 мин. График вылетов доступен онлайн") { + t.Fatal("duration was mistaken for a departure time") + } + if !containsFlightTimeEvidence("Ближайший вылет в 18:40") { + t.Fatal("departure time was not recognized") + } +} + func TestBearerTokenRequiresBearerScheme(t *testing.T) { request := httptest.NewRequest("GET", "/", nil) request.Header.Set("Authorization", "Basic value") -- 2.54.0 From 6a2ed287ba978f92ce176a573fd8c23eae7d7a76 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 25 Aug 2026 18:12:39 +0300 Subject: [PATCH 4/4] Keep flight formatting within task scope --- adapters/research/search_connector.go | 3 --- adapters/research/search_connector_test.go | 6 ------ 2 files changed, 9 deletions(-) diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go index 811b524..d27a73f 100644 --- a/adapters/research/search_connector.go +++ b/adapters/research/search_connector.go @@ -211,9 +211,6 @@ func enrichedSearchQuery(query string) string { if (strings.Contains(lower, "цен") || strings.Contains(lower, "стоим")) && !strings.Contains(lower, "куп") { return trimmed + " цена купить сегодня" } - if (strings.Contains(lower, "рейс") || strings.Contains(lower, "самолет") || strings.Contains(lower, "самолёт") || strings.Contains(lower, "вылет")) && strings.Contains(lower, "ближ") { - return trimmed + " расписание вылеты время" - } return trimmed } diff --git a/adapters/research/search_connector_test.go b/adapters/research/search_connector_test.go index 0f176b1..3011b89 100644 --- a/adapters/research/search_connector_test.go +++ b/adapters/research/search_connector_test.go @@ -55,9 +55,3 @@ func TestEnrichedSearchQueryAddsPriceSignalsOnlyToPriceRequests(t *testing.T) { t.Fatalf("non-price query = %q", got) } } - -func TestEnrichedSearchQueryAddsFlightSignalsToNearestFlightRequests(t *testing.T) { - if got := enrichedSearchQuery("ближайший рейс Москва Казань сегодня 25.08.2026"); got != "ближайший рейс Москва Казань сегодня 25.08.2026 расписание вылеты время" { - t.Fatalf("flight query = %q", got) - } -} -- 2.54.0