Compare commits

...
Author SHA1 Message Date
m ec86c983e3 Route safe text directives through cognition
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m16s
2026-08-26 10:08:42 +03:00
m ea854d5278 Merge pull request 'Allow review of backed research answers' (#402) from codex/household-research-review into main
CI / test (push) Successful in 1m17s
Reviewed-on: #402
2026-08-26 07:01:37 +00:00
m bbecadcec2 Allow review of backed research answers
CI / test (push) Successful in 1m19s
CI / test (pull_request) Successful in 1m22s
2026-08-26 09:58:20 +03:00
m 1c89b30512 Merge pull request 'Reject distance sources without measured evidence' (#401) from codex/household-distance-evidence into main
CI / test (push) Successful in 1m18s
Reviewed-on: #401
2026-08-26 06:50:59 +00:00
m 75a8533939 Reject distance sources without measured evidence
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m18s
2026-08-26 09:47:49 +03:00
m 515a98c470 Merge pull request 'Preserve distance context before search normalization' (#400) from codex/household-distance-context into main
CI / test (push) Successful in 1m32s
Reviewed-on: #400
2026-08-26 06:38:01 +00:00
m d3ad1fd5bb Preserve distance context before search normalization
CI / test (push) Successful in 1m23s
CI / test (pull_request) Successful in 1m20s
2026-08-26 09:34:43 +03:00
m e1802aef12 Merge pull request 'Answer directly without repeating the question' (#399) from codex/household-direct-answer into main
CI / test (push) Successful in 1m34s
Reviewed-on: #399
2026-08-26 06:23:34 +00:00
m 35fc2438dc Answer directly without repeating the question
CI / test (push) Successful in 1m25s
CI / test (pull_request) Successful in 1m21s
2026-08-26 09:19:28 +03:00
m 70335a78e2 Merge pull request 'Fallback to local interpretation when provider fails' (#398) from codex/household-answer-quality into main
CI / test (push) Successful in 1m33s
Reviewed-on: #398
2026-08-26 06:05:14 +00:00
6 changed files with 100 additions and 10 deletions
+2 -2
View File
@@ -36,7 +36,7 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
if err != nil {
return "", err
}
prompt := "Ты редактор финального ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Сначала внутренне проверь, что ответ начинается с содержания, решающего вопрос. Представление, имя помощника, приветствие или предложение дополнительной помощи допустимы только если пользователь сам спросил об этом; иначе убери их. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
prompt := "Ты редактор финального ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Сначала внутренне проверь, что ответ начинается с содержания, решающего вопрос. Не повторяй вопрос пользователя в качестве заголовка или вступления, если это не нужно для смысла. Представление, имя помощника, приветствие или предложение дополнительной помощи допустимы только если пользователь сам спросил об этом; иначе убери их. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
context := []cognitive.ContextExcerpt{}
if strings.TrimSpace(personalMemory) != "" {
context = append(context, cognitive.ContextExcerpt{ID: "personal-memory", SourceKind: "derived-personal-memory", SourceID: "interaction:human-utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: personalMemory})
@@ -91,7 +91,7 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context
if err != nil {
return "", err
}
prompt := "Ты отвечаешь как Домовой на обычный вопрос пользователя. Сначала пойми цель вопроса и верни короткий естественный ответ по устойчивому общеизвестному знанию. Начинай сразу с ответа и не представляйся, если пользователь не спросил об имени. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты.\n\nВопрос пользователя:\n" + userMessage
prompt := "Ты отвечаешь как Домовой на обычное сообщение пользователя. Сначала пойми цель: на вопрос дай короткий естественный ответ по устойчивому общеизвестному знанию, а безопасную просьбу создать текстовый или структурный результат выполни прямо в ответе. Начинай сразу с содержания и не представляйся, если пользователь не спросил об имени. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты и не утверждай выполнение внешнего действия.\n\nСообщение пользователя:\n" + userMessage
context := []cognitive.ContextExcerpt{}
if strings.TrimSpace(personalMemory) != "" {
context = append(context, cognitive.ContextExcerpt{ID: "personal-memory", SourceKind: "derived-personal-memory", SourceID: "interaction:human-utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: personalMemory})
@@ -25,7 +25,7 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.
if request.Message == "" || request.Meaning.SpeechAct == "" {
t.Fatalf("review request did not carry semantic context: %+v", request)
}
if !strings.Contains(request.Message, "ответ начинается с содержания") || !strings.Contains(request.Message, "только если пользователь сам спросил") {
if !strings.Contains(request.Message, "ответ начинается с содержания") || !strings.Contains(request.Message, "Не повторяй вопрос пользователя") || !strings.Contains(request.Message, "только если пользователь сам спросил") {
t.Fatalf("review request lacks the conversational quality contract: %q", request.Message)
}
if len(request.Context) != 1 || !strings.Contains(request.Context[0].Text, "красный цвет") {
+37 -4
View File
@@ -811,9 +811,13 @@ func main() {
continue
}
learnedLexicon := interactiondomain.TaskLexiconFromHistory(conversationHistory)
searchQuery := interactiondomain.SearchQueryWithLexicon(part, learnedLexicon)
// Preserve the original utterance for semantic route compilation
// before removing a search imperative. Distance and route skills
// need the user's relation words (for example "до") to bind a
// remembered current location to the requested destination.
searchQuery := interactiondomain.TravelResearchQuery(part, conversationHistory, now)
searchQuery = interactiondomain.SearchQueryWithLexicon(searchQuery, learnedLexicon)
searchQuery = interactiondomain.TimeDifferenceResearchQuery(searchQuery)
searchQuery = interactiondomain.TravelResearchQuery(searchQuery, conversationHistory, now)
// The research capability may try its configured source and a
// source-recovery fallback. Its caller must leave enough budget for
// both attempts and evidence evaluation.
@@ -862,7 +866,8 @@ func main() {
needsCognition = false
}
}
if planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion && !hasChatRoute(routePlan, chatRouteResearch) && !creativeRequest {
canGenerateTextReply := shouldGenerateNonMutatingTextDirective(semantic, routePlan, helpPath, creativeRequest) && planningReply == deterministicReply
if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && !hasChatRoute(routePlan, chatRouteResearch) && !creativeRequest {
answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second)
answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, effectiveTaskMessage, personalMemory.ContextText(), cognitive.TaskMeaning{
SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
@@ -995,7 +1000,13 @@ func main() {
ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
})
cancelReview()
stableSemanticReply := mediaAttempted || identityReply != "" || memoryInstructionReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
// A source-backed research result is owned by the research capability,
// but its wording is not canonical state. Let the cognitive editor turn
// it into a useful human answer while reviewPreservesResearchEvidence
// still requires the source evidence to remain present. Information
// requests without research (identity, channel state and so on) remain
// stable semantic replies and cannot be rewritten here.
stableSemanticReply := mediaAttempted || identityReply != "" || memoryInstructionReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || (semantic.InformationRequestKind != "" && !(researchAttempted && researchBacked)) || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
if reviewErr != nil {
log.Printf("cognitive response review unavailable: %v", reviewErr)
} else if reviewMayReplacePlanningReply(stableSemanticReply, stableKnowledgeReply, researchAttempted, researchBacked, reviewedReply, planningReply) {
@@ -1843,6 +1854,9 @@ func researchFindingMatchesIntent(query string, finding epistemics.ResearchFindi
if ticketSearchQuery(query) && !ticketFindingMatchesDestination(query, finding) {
return false
}
if distanceResearchQuery(query) && !containsDistanceEvidence(finding.Excerpt.Text) {
return false
}
if !nearestFlightQuery(query) {
return true
}
@@ -1872,6 +1886,15 @@ func researchFindingMatchesIntent(query string, finding epistemics.ResearchFindi
return true
}
func distanceResearchQuery(query string) bool {
lower := strings.ToLower(query)
return strings.Contains(lower, "расстоян") || strings.Contains(lower, "километр")
}
func containsDistanceEvidence(value string) bool {
return regexp.MustCompile(`(?i)\b\d[\d\s]*\s*(?:км|километр)`).MatchString(value)
}
func ticketSearchQuery(query string) bool {
return interactiondomain.TicketSearchDestination(query) != ""
}
@@ -2002,6 +2025,16 @@ func stableKnowledgeAnswer(answer string) bool {
return strings.TrimSpace(answer) != "" && strings.TrimSpace(answer) != "NEEDS_VERIFIED_RESEARCH"
}
// shouldGenerateNonMutatingTextDirective identifies a request that can be
// completed entirely in the reply. It excludes specialized routes and durable
// work, so cognition can produce content but cannot stand in for an executor.
func shouldGenerateNonMutatingTextDirective(semantic interactiondomain.SemanticResult, routes []chatRoute, helpPath work.HelpPath, creativeRequest bool) bool {
if semantic.Interpretation.SpeechAct != interaction.SpeechActDirective || creativeRequest || helpPath.Kind != "" || helpPath.NeedsDurableWork {
return false
}
return !hasChatRoute(routes, chatRouteResearch) && !hasChatRoute(routes, chatRouteMedia) && !hasChatRoute(routes, chatRouteAgent)
}
func reviewPreservesResearchEvidence(reviewed, draft string) bool {
if strings.Contains(draft, "http://") || strings.Contains(draft, "https://") {
return strings.Contains(reviewed, "http://") || strings.Contains(reviewed, "https://")
+40
View File
@@ -13,6 +13,7 @@ import (
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/epistemics"
"git.cin.su/m/Assistant/contracts/interaction"
"git.cin.su/m/Assistant/contracts/work"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
@@ -70,6 +71,18 @@ func TestTicketReplyRejectsGenericCategoryPageWithoutDestination(t *testing.T) {
}
}
func TestDistanceReplyRejectsCalculatorPageWithoutDistance(t *testing.T) {
query := "расстояние между Москве и Сочи в километрах по прямой и по дороге"
calculator := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Расчет расстояний"}, Excerpt: epistemics.SourceExcerpt{Text: "Калькулятор рассчитывает точное расстояние между городами."}}
if researchFindingMatchesIntent(query, calculator) {
t.Fatal("distance calculator landing page was presented without a measured distance")
}
measured := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Маршрут"}, Excerpt: epistemics.SourceExcerpt{Text: "Расстояние по дороге составляет 1 620 км."}}
if !researchFindingMatchesIntent(query, measured) {
t.Fatal("measured distance evidence was rejected")
}
}
func TestResearchReplyRejectsGenericLandingPageForConcreteIdentifier(t *testing.T) {
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
State: epistemics.FindingSupported,
@@ -101,6 +114,15 @@ func TestResearchIdentifiersExcludeSearchDate(t *testing.T) {
}
}
func TestResearchQueryKeepsDistanceRelationBeforeSearchNormalization(t *testing.T) {
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
query := interactiondomain.TravelResearchQuery("сколько километров до Сочи?", history, time.Time{})
query = interactiondomain.SearchQueryWithLexicon(query, interactiondomain.TaskLexicon{})
if query != "расстояние между Москве и Сочи в километрах по прямой и по дороге" {
t.Fatalf("distance query lost remembered origin: %q", query)
}
}
func TestResponseReviewPreservesAttemptedResearchOutcome(t *testing.T) {
if reviewMayReplacePlanningReply(false, false, true, false, "Уточните аэропорт.", "Пока не смог получить проверяемый ответ.") {
t.Fatal("review replaced an honest unbacked research outcome")
@@ -117,6 +139,24 @@ func TestResponseReviewPreservesAttemptedResearchOutcome(t *testing.T) {
if !reviewMayReplacePlanningReply(true, true, false, false, "Санкт-Петербург — город в России.", "Меня зовут Домовой. Санкт-Петербург — город в России.") {
t.Fatal("stable-knowledge draft must be eligible for semantic quality review")
}
// The caller must not mark a source-backed research result as immutable
// semantic state: it may be rewritten, but only with its evidence retained.
if !reviewMayReplacePlanningReply(false, false, true, true, "Из Москвы в Сочи можно добраться самолётом или поездом. Источник: https://example.test", "Сырые результаты. Источник: https://example.test") {
t.Fatal("source-backed research draft must be eligible for human-facing review")
}
}
func TestNonMutatingTextDirectiveCanUseCognitiveReply(t *testing.T) {
semantic := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: interaction.SpeechActDirective}}
if !shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{}, false) {
t.Fatal("ordinary formatting directive was not eligible for a cognitive reply")
}
if shouldGenerateNonMutatingTextDirective(semantic, []chatRoute{{Kind: chatRouteMedia}}, work.HelpPath{}, false) {
t.Fatal("media route was incorrectly replaced by a textual reply")
}
if shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{NeedsDurableWork: true}, false) {
t.Fatal("durable work was incorrectly replaced by a textual reply")
}
}
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
+10 -3
View File
@@ -196,7 +196,7 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter
}
if primary == interaction.SpeechActQuestion && !hasQuestionMark && !containsMeaningfulInterrogative(tokens) &&
!startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж") {
if len(tokens) > 0 && containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") {
if len(tokens) > 0 && containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ", "вывед") {
primary, secondary = interaction.SpeechActDirective, interaction.SpeechActUnknown
} else {
primary, secondary = interaction.SpeechActAssertion, interaction.SpeechActUnknown
@@ -279,7 +279,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь", "вывед"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
@@ -494,6 +494,13 @@ func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens
if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "спрос", "обрати", "посчитай", "рассчитай")) {
return true
}
// A harmless imperative can ask for a textual or structural result even
// without a question mark. Treat it as a cognition-worthy request instead
// of silently recording it as an assertion. Consequential instructions are
// kept on their explicit confirmation/capability paths.
if primary == interaction.SpeechActDirective && !likelyConsequential(tokens) {
return true
}
return false
}
@@ -904,7 +911,7 @@ func followsNibud(tokens []string) bool {
}
func startsWithDirectiveWhatNibud(tokens []string) bool {
if len(tokens) < 2 || !containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") {
if len(tokens) < 2 || !containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ", "вывед") {
return false
}
for index, token := range tokens {
+10
View File
@@ -112,6 +112,16 @@ func TestDeterministicCandidateTreatsModelRequestAsDirectiveNeedingCognition(t *
}
}
func TestDeterministicCandidateTreatsFormattingImperativeAsCognitionWorthyDirective(t *testing.T) {
candidate := BuildDeterministicCandidate("выведи пустую таблицу из трех колонок и пяти строк", "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
t.Fatalf("formatting request was not recognized as a directive: %+v", candidate)
}
if !candidate.NeedsCognition {
t.Fatalf("formatting request must reach cognitive reasoning: %+v", candidate)
}
}
func TestAWordModelDoesNotTriggerModelInventoryOrVerification(t *testing.T) {
candidate := BuildDeterministicCandidate("модель должна понимать смысл вопроса", "", time.Unix(10, 0).UnixMilli())
for _, hint := range candidate.CapabilityHints {