Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb616310d | ||
|
|
5a9cf7a22d | ||
|
|
d649f79ca4 | ||
|
|
ffce659332 | ||
|
|
24b30bf669 | ||
|
|
1bd9137761 | ||
|
|
f895c5a104 | ||
|
|
d0074709aa | ||
|
|
982eb6f49b | ||
|
|
b1ee085de4 | ||
|
|
2c66a44910 | ||
|
|
b95714a71e | ||
|
|
6183d811f1 | ||
|
|
6a2ed287ba | ||
|
|
d342728b46 | ||
|
|
f121445f24 | ||
|
|
124871da76 | ||
|
|
2062094228 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestSpecializedInformationReplyIsNotOverwrittenByResearch(t *testing.T) {
|
||||
for _, kind := range []interaction.InformationRequestKind{
|
||||
interaction.InformationRequestMemoryInventory,
|
||||
interaction.InformationRequestTimeDifference,
|
||||
interaction.InformationRequestCapabilityInventory,
|
||||
interaction.InformationRequestCognitiveModelInventory,
|
||||
interaction.InformationRequestCognitiveChannelStatus,
|
||||
interaction.InformationRequestCognitiveRecovery,
|
||||
} {
|
||||
if shouldRunGeneralResearch(kind) {
|
||||
t.Fatalf("specialized information request %q must bypass general research", kind)
|
||||
}
|
||||
}
|
||||
if !shouldRunGeneralResearch("") {
|
||||
t.Fatal("ordinary research requests must still use general research")
|
||||
}
|
||||
}
|
||||
@@ -632,6 +632,13 @@ func main() {
|
||||
cognitiveAvailable := true
|
||||
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
|
||||
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
|
||||
// A selected specialized media route already has an executable semantic
|
||||
// outcome. Do not block it on the general cognitive channel: image
|
||||
// generation must reach its own adapter even when cognition is degraded.
|
||||
if hasChatRoute(routePlan, chatRouteMedia) {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if identityReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
@@ -762,7 +769,7 @@ func main() {
|
||||
needsCognition = false
|
||||
}
|
||||
researchBacked := false
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
|
||||
if planID, idErr := ids.New(); idErr == nil {
|
||||
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||
replies := make([]string, 0, len(parts))
|
||||
@@ -771,10 +778,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 +829,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 +930,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 +939,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 {
|
||||
@@ -1579,6 +1589,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRunGeneralResearch(kind interaction.InformationRequestKind) bool {
|
||||
return kind == ""
|
||||
}
|
||||
|
||||
func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword string, sessionKey []byte, now func() time.Time) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
authenticated := identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r))
|
||||
@@ -1719,6 +1733,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 +1744,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 == "" {
|
||||
@@ -1774,7 +1800,13 @@ func humanMemoryInventory(history []interaction.HumanUtterance, assistantName st
|
||||
lower := strings.ToLower(message)
|
||||
if (strings.Contains(lower, "нравится") || strings.Contains(lower, "люблю")) && utterance.ReceivedAtUnixMillis >= latestPreferenceAt {
|
||||
latestPreferenceAt = utterance.ReceivedAtUnixMillis
|
||||
latestPreference = message
|
||||
latestPreference = strings.TrimSpace(message)
|
||||
for _, prefix := range []string{"запомни:", "запомни", "помни:"} {
|
||||
if strings.HasPrefix(strings.ToLower(latestPreference), prefix) {
|
||||
latestPreference = strings.TrimSpace(latestPreference[len(prefix):])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if latestPreference != "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -250,6 +250,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
if explicitCorrection {
|
||||
scores[interaction.SpeechActCorrection] += 6
|
||||
}
|
||||
if containsStemInTokens(tokens, "опечат", "ошибк", "исправлен") && containsStemInTokens(tokens, "исправ", "поправ") {
|
||||
scores[interaction.SpeechActCorrection] += 6
|
||||
}
|
||||
first := ""
|
||||
if len(tokens) > 0 {
|
||||
first = tokens[0]
|
||||
@@ -301,6 +304,10 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
scores[interaction.SpeechActPreference] += 8
|
||||
scores[interaction.SpeechActAssertion] = 0
|
||||
}
|
||||
if containsToken(tokens, "запомни") && containsStemInTokens(tokens, "нрав", "люб", "предпочит") {
|
||||
scores[interaction.SpeechActPreference] += 6
|
||||
scores[interaction.SpeechActAssertion] = 0
|
||||
}
|
||||
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
|
||||
scores[interaction.SpeechActAssertion] += 5
|
||||
}
|
||||
@@ -496,19 +503,35 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
|
||||
add("WEB_RESEARCH")
|
||||
}
|
||||
if isCreativeRequestTokens(tokens) {
|
||||
if isImageCreationRequestTokens(tokens) {
|
||||
// A correction that quotes a creative phrase is still a correction;
|
||||
// do not execute the quoted phrase as a side effect.
|
||||
if isImageCreationRequestTokens(tokens) && primary != interaction.SpeechActCorrection && secondary != interaction.SpeechActCorrection {
|
||||
add("IMAGE_GENERATION")
|
||||
} else {
|
||||
add("COGNITIVE_REASONING")
|
||||
}
|
||||
}
|
||||
if containsStemInTokens(tokens, "голосов", "аудио", "послуш") {
|
||||
add("SPEECH_TO_TEXT")
|
||||
}
|
||||
if containsStemInTokens(tokens, "фотограф", "фото", "картин", "изображ") && containsStemInTokens(tokens, "прочит", "текст", "надпис") {
|
||||
add("OCR")
|
||||
} else if containsStemInTokens(tokens, "фотограф", "фото", "картин", "изображ") {
|
||||
add("IMAGE_UNDERSTANDING")
|
||||
}
|
||||
if containsStemInTokens(tokens, "видео", "ролик") {
|
||||
add("VIDEO_UNDERSTANDING")
|
||||
}
|
||||
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
|
||||
add("SCHEDULE_REMINDER")
|
||||
}
|
||||
if containsStemInTokens(tokens, "поставь", "создай", "добавь") && containsStemInTokens(tokens, "цел", "задач", "план") {
|
||||
add("COGNITIVE_REASONING")
|
||||
}
|
||||
if isExplicitEmailAction(tokens) {
|
||||
add("SEND_EMAIL")
|
||||
}
|
||||
if containsToken(tokens, "код") || containsToken(tokens, "патч") || containsToken(tokens, "текст") || containsToken(tokens, "главной") || containsToken(tokens, "чате") {
|
||||
if containsToken(tokens, "код") || containsToken(tokens, "патч") || containsToken(tokens, "текст") || containsToken(tokens, "главной") || containsToken(tokens, "чате") || containsToken(tokens, "проект") || containsStemInTokens(tokens, "тест") {
|
||||
if primary == interaction.SpeechActDirective || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActDirective {
|
||||
add("MODIFY_CODE")
|
||||
add("RUN_TESTS")
|
||||
@@ -529,6 +552,9 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
|
||||
if containsInterrogative(tokens) && containsArithmetic(tokens) {
|
||||
add("COGNITIVE_REASONING")
|
||||
}
|
||||
if isCreativeRequestTokens(tokens) && containsStemInTokens(tokens, "погод", "ресторан", "кафе", "билет", "рейс", "цен", "найд", "узнай", "проверь") {
|
||||
add("WEB_RESEARCH")
|
||||
}
|
||||
if containsToken(tokens, "свободно") || containsToken(tokens, "гб") {
|
||||
add("VERIFY_FACT")
|
||||
}
|
||||
@@ -567,8 +593,9 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
|
||||
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
||||
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
||||
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
|
||||
travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт")
|
||||
return currentCue || observedStateCue || placeSearchCue || travelSearchCue
|
||||
travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут")
|
||||
timeDifferenceCue := containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час")
|
||||
return currentCue || observedStateCue || placeSearchCue || travelSearchCue || timeDifferenceCue
|
||||
}
|
||||
|
||||
// approximateToken catches ordinary keyboard slips without turning routing
|
||||
|
||||
@@ -254,6 +254,16 @@ func TestMemoryInventoryRequestIsSemantic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberedPreferenceIsNotRenderedAsIdentity(t *testing.T) {
|
||||
result := BuildDeterministicCandidate("запомни: мне нравится красный цвет", "", time.Unix(10, 0).UnixMilli())
|
||||
if result.PrimarySpeechAct != interaction.SpeechActPreference {
|
||||
t.Fatalf("expected remembered preference, got %+v", result)
|
||||
}
|
||||
if got := semanticReply("запомни: мне нравится красный цвет", SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: result.PrimarySpeechAct}}, nil); got != "Хорошо, учту это дальше." {
|
||||
t.Fatalf("unexpected preference reply %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
|
||||
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{"id":"HH50-001","kind":"question","message":"ты кто?","outcome":"identity"},
|
||||
{"id":"HH50-002","kind":"question","message":"как тебя зовут?","outcome":"identity"},
|
||||
{"id":"HH50-003","kind":"task","message":"я назову тебя Дед, запомни","outcome":"memory"},
|
||||
{"id":"HH50-004","kind":"task","message":"теперь тебя зовут Фея","outcome":"memory"},
|
||||
{"id":"HH50-005","kind":"question","message":"что ты умеешь?","outcome":"conversation"},
|
||||
{"id":"HH50-006","kind":"task","message":"запомни: мне нравится красный цвет","outcome":"memory"},
|
||||
{"id":"HH50-007","kind":"question","message":"что ты помнишь обо мне?","outcome":"memory"},
|
||||
{"id":"HH50-008","kind":"question","message":"выведи мою память таблицей","outcome":"memory"},
|
||||
{"id":"HH50-009","kind":"question","message":"какая сейчас погода в Казани?","outcome":"research"},
|
||||
{"id":"HH50-010","kind":"question","message":"какая разница во времени между Москвой и Шанхаем?","outcome":"research"},
|
||||
{"id":"HH50-011","kind":"assertion","message":"я сейчас в Москве","outcome":"memory"},
|
||||
{"id":"HH50-012","kind":"question","message":"сколько километров до Сочи?","outcome":"research"},
|
||||
{"id":"HH50-013","kind":"question","message":"как можно добраться до Сочи?","outcome":"research"},
|
||||
{"id":"HH50-014","kind":"question","message":"какой ближайший рейс до Казани?","outcome":"research"},
|
||||
{"id":"HH50-015","kind":"question","message":"найди ближайший ресторан в Сочи","outcome":"research"},
|
||||
{"id":"HH50-016","kind":"question","message":"назови доступные модели","outcome":"conversation"},
|
||||
{"id":"HH50-017","kind":"question","message":"какая модель отвечает?","outcome":"conversation"},
|
||||
{"id":"HH50-018","kind":"question","message":"что ты можешь сделать без внешних моделей?","outcome":"conversation"},
|
||||
{"id":"HH50-019","kind":"question","message":"проверь, работает ли интернет","outcome":"research"},
|
||||
{"id":"HH50-020","kind":"question","message":"почему ты не смог найти ответ?","outcome":"conversation"},
|
||||
{"id":"HH50-021","kind":"question","message":"найди на WB бренд marka-wear","outcome":"research"},
|
||||
{"id":"HH50-022","kind":"question","message":"проверь цену на видеокарту RTX 4090","outcome":"research"},
|
||||
{"id":"HH50-023","kind":"question","message":"найди билеты до Ялты","outcome":"research"},
|
||||
{"id":"HH50-024","kind":"question","message":"покажи ближайшие рейсы до Сочи","outcome":"research"},
|
||||
{"id":"HH50-025","kind":"question","message":"какой ближайший самолёт из Москвы в Казань сегодня?","outcome":"research"},
|
||||
{"id":"HH50-026","kind":"question","message":"нарисуй птицу","outcome":"media"},
|
||||
{"id":"HH50-027","kind":"task","message":"нарисуй зелёную птицу","outcome":"media"},
|
||||
{"id":"HH50-028","kind":"task","message":"нарисуй коня","outcome":"media"},
|
||||
{"id":"HH50-029","kind":"task","message":"нарисуй бурого медведя","outcome":"media"},
|
||||
{"id":"HH50-030","kind":"task","message":"нарисуй птицу в HD","outcome":"media"},
|
||||
{"id":"HH50-031","kind":"task","message":"нарисуй круг","outcome":"media"},
|
||||
{"id":"HH50-032","kind":"question","message":"расскажи сказку о Колобке","outcome":"conversation"},
|
||||
{"id":"HH50-033","kind":"task","message":"придумай короткую шутку","outcome":"conversation"},
|
||||
{"id":"HH50-034","kind":"task","message":"исправь опечатку: нарисуй прицу","outcome":"conversation"},
|
||||
{"id":"HH50-035","kind":"task","message":"поставь цель подготовиться к поездке","outcome":"action"},
|
||||
{"id":"HH50-036","kind":"task","message":"напомни завтра в 10 купить билет","outcome":"action"},
|
||||
{"id":"HH50-037","kind":"question","message":"что осталось сделать по цели?","outcome":"action"},
|
||||
{"id":"HH50-038","kind":"task","message":"проверь погоду в Казани и найди рядом кафе","outcome":"multi"},
|
||||
{"id":"HH50-039","kind":"task","message":"узнай погоду в Сочи и нарисуй птицу","outcome":"multi"},
|
||||
{"id":"HH50-040","kind":"task","message":"напомни вечером позвонить и проверь погоду","outcome":"multi"},
|
||||
{"id":"HH50-041","kind":"question","message":"послушай это голосовое сообщение","outcome":"audio"},
|
||||
{"id":"HH50-042","kind":"question","message":"что на этой фотографии?","outcome":"vision"},
|
||||
{"id":"HH50-043","kind":"question","message":"прочитай текст на фотографии","outcome":"ocr"},
|
||||
{"id":"HH50-044","kind":"question","message":"что происходит на этом видео?","outcome":"video"},
|
||||
{"id":"HH50-045","kind":"assertion","message":"источники противоречат друг другу","outcome":"conversation"},
|
||||
{"id":"HH50-046","kind":"question","message":"основной runtime сейчас недоступен","outcome":"research"},
|
||||
{"id":"HH50-047","kind":"task","message":"купи билет до Казани","outcome":"confirmation"},
|
||||
{"id":"HH50-048","kind":"task","message":"отправь письмо о поездке","outcome":"confirmation"},
|
||||
{"id":"HH50-049","kind":"task","message":"измени проект и запусти тесты","outcome":"agent"},
|
||||
{"id":"HH50-050","kind":"question","message":"не получилось ли действие?","outcome":"conversation"}
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
package household_scenarios
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
type checklistItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
func TestHouseholdChecklist50ReachesPositiveSemanticReaction(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("checklist_50.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var items []checklistItem
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 50 {
|
||||
t.Fatalf("checklist has %d items, want exactly 50", len(items))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
if item.ID == "" || item.Kind == "" || strings.TrimSpace(item.Message) == "" || item.Outcome == "" {
|
||||
t.Fatalf("incomplete checklist item: %+v", item)
|
||||
}
|
||||
if seen[item.ID] {
|
||||
t.Fatalf("duplicate checklist item %q", item.ID)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
checkSemanticReaction(t, item)
|
||||
}
|
||||
}
|
||||
|
||||
func checkSemanticReaction(t *testing.T, item checklistItem) {
|
||||
t.Helper()
|
||||
candidate := interactiondomain.BuildDeterministicCandidate(item.Message, "", time.Unix(10, 0).UnixMilli())
|
||||
has := func(kind capability.CapabilityKind) bool {
|
||||
for _, hint := range candidate.CapabilityHints {
|
||||
if capability.CapabilityKind(hint) == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch item.Outcome {
|
||||
case "identity", "memory", "conversation", "confirmation":
|
||||
if has(capability.CapabilityKindWebResearch) || has(capability.CapabilityKindImageGeneration) {
|
||||
t.Errorf("%s %q routed unrelated capability for %s: %+v", item.ID, item.Message, item.Outcome, candidate)
|
||||
}
|
||||
case "research":
|
||||
if !has(capability.CapabilityKindWebResearch) {
|
||||
t.Errorf("%s %q did not select research route: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
case "media":
|
||||
if !has(capability.CapabilityKindImageGeneration) {
|
||||
t.Errorf("%s %q did not select image route: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
case "action":
|
||||
if !has(capability.CapabilityKindScheduleReminder) && !has(capability.CapabilityKindCognitiveReasoning) && !candidate.NeedsCognition {
|
||||
t.Errorf("%s %q has no action route or cognitive handoff: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
case "agent":
|
||||
if !candidate.NeedsCognition && len(candidate.CapabilityHints) == 0 {
|
||||
t.Errorf("%s %q has no agent route or cognitive handoff: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
case "multi":
|
||||
if len(candidate.CapabilityHints) < 2 {
|
||||
t.Errorf("%s %q was not decomposed into multiple capabilities: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
case "audio", "vision", "ocr", "video":
|
||||
if len(candidate.CapabilityHints) == 0 && !candidate.NeedsCognition {
|
||||
t.Errorf("%s %q has no modality route or cognitive handoff: %+v", item.ID, item.Message, candidate)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("%s uses unknown outcome %q", item.ID, item.Outcome)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user