Carry forward previous chat tasks on retry #281

Merged
m merged 2 commits from codex/chat-semantic-inventory into main 2026-08-24 19:56:44 +00:00
5 changed files with 110 additions and 7 deletions
+23 -6
View File
@@ -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)
+19 -1
View File
@@ -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{
+8
View File
@@ -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 {
+36
View File
@@ -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), "теперь ")) {
@@ -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, "")