diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index c900041..efef5c2 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -507,11 +507,26 @@ func main() { http.Error(w, "project focus unavailable", http.StatusServiceUnavailable) return } + var effectiveTaskMessage = utterance.Content + var previousTask *interaction.HumanUtterance + if request.ConversationID != "" && interactiondomain.IsRetryRequest(utterance.Content) { + if history, historyErr := interactionRepository.Utterances(r.Context(), conversation.ID); historyErr == nil && len(history) > 0 { + candidate := history[len(history)-1] + previousTask = &candidate + effectiveTaskMessage = candidate.Content + } + } semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now) if err != nil { http.Error(w, "interpret chat message", http.StatusServiceUnavailable) return } + if previousTask != nil { + previousSemantic, previousErr := interactiondomain.InterpretUtterance(*previousTask, projects, previousFocus, now) + if previousErr == nil { + semantic = interactiondomain.CarryForwardPreviousTask(semantic, previousSemantic) + } + } routePlan := buildChatRoutePlan(semantic) cognitiveAttempted := false cognitiveAvailable := true @@ -525,9 +540,11 @@ func main() { projectNames = append(projectNames, ref.ProjectDisplayName) } } - if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted { + cognitiveUtterance := utterance + cognitiveUtterance.Content = effectiveTaskMessage + if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, cognitiveUtterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted { if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil { - semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus) + semantic = interactiondomain.ApplyCognitiveCandidate(semantic, effectiveTaskMessage, candidate, previousFocus) } else { cognitiveAvailable = false } @@ -546,7 +563,7 @@ func main() { responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{ SourceUtteranceID: string(utterance.ID), SourceIntentID: string(intent.ID), - Message: utterance.Content, + Message: effectiveTaskMessage, RequestedAtUnixMillis: now.UnixMilli(), }) if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection { @@ -594,7 +611,7 @@ func main() { ProjectID: work.ProjectRef(projectID), ProjectDisplayName: activeFocusName, ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance), - Message: utterance.Content, + Message: effectiveTaskMessage, SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct), SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct), AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification), @@ -630,8 +647,8 @@ func main() { if planID, idErr := ids.New(); idErr == nil { researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second) result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{ - Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID("plan:" + planID), Question: utterance.Content, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, - PropositionKey: "question:" + string(utterance.ID), Query: utterance.Content, + Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID("plan:" + planID), Question: effectiveTaskMessage, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, + PropositionKey: "question:" + string(utterance.ID), Query: effectiveTaskMessage, }) cancelResearch() planningReply, researchBacked = renderResearchReply(result, researchErr) diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index a707706..f595340 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -213,7 +213,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo } questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}} conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"} - investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри"} + investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"} directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"} explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"} preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"} @@ -300,6 +300,24 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo return scores } +// IsRetryRequest identifies a short continuation that asks Domovoy to repeat +// the previous task. It deliberately carries no subject-specific knowledge; +// the conversation owner supplies the previous task when available. +func IsRetryRequest(message string) bool { + tokens := semanticTokens(message) + if len(tokens) == 0 || len(tokens) > 8 { + return false + } + hasRetryCue := false + for _, token := range tokens { + if containsStem(token, "повтор", "снов", "попроб", "ещ") { + hasRetryCue = true + break + } + } + return hasRetryCue +} + func topAct(scores map[interaction.SpeechAct]int) (interaction.SpeechAct, int) { bestAct, bestScore := interaction.SpeechActUnknown, -1 for _, act := range []interaction.SpeechAct{ diff --git a/modules/interaction/domain/chat_test.go b/modules/interaction/domain/chat_test.go index ace774b..581be75 100644 --- a/modules/interaction/domain/chat_test.go +++ b/modules/interaction/domain/chat_test.go @@ -164,6 +164,14 @@ func TestRequestToClarifyCurrentFactRequestsWebResearch(t *testing.T) { t.Fatalf("clarification request for current fact was not routed to WEB_RESEARCH: %+v", candidate) } +func TestRetryRequestIsAContinuationCue(t *testing.T) { + for _, message := range []string{"ещё раз попробуй", "повтори", "попробуй снова"} { + if !IsRetryRequest(message) { + t.Fatalf("expected retry cue for %q", message) + } + } +} + func TestDeterministicCandidateKeepsFirstPersonCompletedWorkAsAssertion(t *testing.T) { candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli()) if candidate.PrimarySpeechAct != interaction.SpeechActAssertion { diff --git a/modules/interaction/domain/semantics.go b/modules/interaction/domain/semantics.go index a9a71da..1fce210 100644 --- a/modules/interaction/domain/semantics.go +++ b/modules/interaction/domain/semantics.go @@ -136,6 +136,42 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in return result } +// CarryForwardPreviousTask transfers only the routing meaning of a previous +// user task to a short retry utterance. The new utterance keeps its own IDs +// and is persisted separately, while the caller may use the previous content +// as the effective task input for an adapter or capability. +func CarryForwardPreviousTask(current, previous SemanticResult) SemanticResult { + if previous.Interpretation.Clarification == interaction.ClarificationRequired || + previous.Interpretation.SpeechAct == interaction.SpeechActAssertion || + previous.Interpretation.SpeechAct == interaction.SpeechActUnknown { + return current + } + current.Interpretation.SpeechAct = previous.Interpretation.SpeechAct + current.Interpretation.SecondarySpeechAct = previous.Interpretation.SecondarySpeechAct + current.Interpretation.ConfidenceMillis = previous.Interpretation.ConfidenceMillis + current.Interpretation.Source = "CONVERSATION_CONTINUATION" + current.Interpretation.AssertionVerification = previous.Interpretation.AssertionVerification + current.Interpretation.RequestedOutcome = previous.Interpretation.RequestedOutcome + current.Interpretation.Clarification = previous.Interpretation.Clarification + current.Interpretation.NeedsCognition = previous.Interpretation.NeedsCognition + current.Interpretation.Applicability = previous.Interpretation.Applicability + current.Interpretation.SelectionReason = "short retry carries forward the previous user task" + current.NeedsCognition = previous.NeedsCognition + current.CapabilityHints = append([]string(nil), previous.CapabilityHints...) + current.InformationRequestKind = previous.InformationRequestKind + if len(current.Candidates) > 0 && len(previous.Candidates) > 0 { + candidate := previous.Candidates[0] + candidate.ID = current.Candidates[0].ID + candidate.UtteranceID = current.Interpretation.UtteranceID + candidate.Source = interaction.CandidateSourceCognitive + candidate.SelectionState = interaction.CandidateSelected + candidate.RecordedAtUnixMillis = current.Interpretation.RecordedAtUnixMillis + current.Candidates[0] = candidate + } + current.Reply = "Повторяю предыдущую задачу." + return current +} + func explicitProjectFocus(message string, projects []situation.ProjectDescriptor) *interaction.ProjectReference { lower := strings.ToLower(message) if !(strings.Contains(lower, "работаем с") || strings.Contains(lower, "вернемся к") || strings.Contains(lower, "вернёмся к") || strings.HasPrefix(strings.TrimSpace(lower), "теперь ")) { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index ad8c2f1..8adf778 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -136,6 +136,30 @@ func TestInterpretUtterancePunctuationFreeArithmeticStillRequestsCognition(t *te } } +func TestCarryForwardPreviousTaskPreservesRoutingForShortRetry(t *testing.T) { + current := SemanticResult{ + Interpretation: interaction.UtteranceInterpretation{UtteranceID: "retry", SpeechAct: interaction.SpeechActAssertion}, + Candidates: []interaction.SemanticCandidate{{ID: "current", UtteranceID: "retry"}}, + } + previous := SemanticResult{ + Interpretation: interaction.UtteranceInterpretation{ + UtteranceID: "previous", + SpeechAct: interaction.SpeechActRequestToInvestigate, + RequestedOutcome: "узнать текущую погоду", + NeedsCognition: false, + Applicability: interaction.SemanticApplicable, + Clarification: interaction.ClarificationNotRequired, + RecordedAtUnixMillis: 100, + }, + Candidates: []interaction.SemanticCandidate{{ID: "previous-candidate", PrimarySpeechAct: interaction.SpeechActRequestToInvestigate, CapabilityHints: []string{"WEB_RESEARCH"}}}, + CapabilityHints: []string{"WEB_RESEARCH"}, + } + result := CarryForwardPreviousTask(current, previous) + if result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate || result.CapabilityHints[0] != "WEB_RESEARCH" || result.Candidates[0].UtteranceID != "retry" { + t.Fatalf("previous routing was not carried forward: %#v", result) + } +} + func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) { for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} { result := interpretForTest(t, message, nil, "")