Isolate media cancellation and improve travel answers #363

Merged
m merged 5 commits from codex/household-media-queue-isolation into main 2026-08-25 15:18:23 +00:00
6 changed files with 148 additions and 5 deletions
+39
View File
@@ -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 {
+40
View File
@@ -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)
}
}
+19 -4
View File
@@ -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 {
@@ -1719,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) {
@@ -1727,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 == "" {
+12
View File
@@ -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")
@@ -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 {
+28 -1
View File
@@ -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, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр")
}