Compare commits

..
Author SHA1 Message Date
m 4e54ace76b Route local place searches to relevant evidence
CI / test (pull_request) Successful in 1m19s
CI / test (push) Successful in 47s
2026-08-26 10:40:15 +03:00
m f329224d12 Merge pull request 'Keep nearest flight reply focused' (#405) from codex/household-text-cognition-path into main
CI / test (push) Successful in 1m21s
Reviewed-on: #405
2026-08-26 07:33:36 +00:00
m bb76171af4 Merge remote-tracking branch 'origin/main' into codex/household-text-cognition-path
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m23s
2026-08-26 10:30:15 +03:00
m 213f06661d Keep nearest flight reply focused
CI / test (push) Failing after 59s
CI / test (pull_request) Failing after 58s
2026-08-26 10:27:54 +03:00
m d350b9a565 Merge pull request 'Allow reply-only cognition for text directives' (#404) from codex/household-text-cognition-path into main
CI / test (push) Successful in 1m24s
Reviewed-on: #404
2026-08-26 07:21:07 +00:00
m f862c3ac13 Allow reply-only cognition for text directives
CI / test (push) Successful in 1m22s
CI / test (pull_request) Successful in 1m17s
2026-08-26 10:17:51 +03:00
m 5e18eb6082 Merge pull request 'Route safe text directives through cognition' (#403) from codex/household-text-directives into main
CI / test (push) Successful in 1m21s
Reviewed-on: #403
2026-08-26 07:12:00 +00:00
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
m 41067a0b16 Merge branch 'main' into codex/household-answer-quality
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m18s
2026-08-26 06:00:13 +00:00
m fba05d5ed4 Fallback to local interpretation when provider fails
CI / test (push) Failing after 56s
CI / test (pull_request) Failing after 56s
2026-08-26 08:59:49 +03:00
m 6e14ba1727 Merge pull request 'Review stable knowledge replies for conversational quality' (#397) from codex/household-answer-quality into main
CI / test (push) Successful in 2m0s
Reviewed-on: #397
2026-08-26 05:53:37 +00:00
m 9c6e2097b1 Review stable knowledge replies for conversational quality
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m27s
2026-08-26 08:48:01 +03:00
m 96559f6ab8 Merge pull request 'Preserve completed household answers in web chat' (#396) from codex/household-core-reply into main
CI / test (push) Successful in 1m25s
Reviewed-on: #396
2026-08-26 04:58:04 +00:00
10 changed files with 250 additions and 18 deletions
+8 -2
View File
@@ -67,12 +67,18 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R
}
g.observe(r.Context(), selected, response, started)
if response.FinishReason != cognitive.FinishCompleted {
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: response.FinishReason, Usage: response.Usage})
// A selected provider can become unavailable after discovery. Semantic
// routing must still retain the deterministic interpretation and use the
// local lifeboat rather than turn an ordinary conversation into a
// degraded reply solely because one provider failed.
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
return
}
candidate, ok := parseInterpretationCandidate(response.AdvisoryText)
if !ok {
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishInvalidStructuredOutput, Usage: response.Usage})
// Structured interpretation is a transport contract. A malformed model
// answer is not a reason to discard the available local fallback.
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
return
}
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage})
@@ -1,9 +1,15 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
@@ -24,3 +30,32 @@ func TestBuiltinInterpretationTimeoutRetainsDeterministicCandidate(t *testing.T)
t.Fatalf("candidate did not retain deterministic meaning: %+v", response.Candidate)
}
}
func TestInterpretationProviderFailureUsesLocalLifeboat(t *testing.T) {
selected := gatewaySelection()
selected.Binding.DataPolicy = capability.DataPolicyLocalOnly
primary := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{FinishReason: cognitive.FinishProviderUnavailable}}
lifeboat := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{AdvisoryText: `{"primary_speech_act":"QUESTION","secondary_speech_act":"","confidence_millis":900,"applicability":"APPLICABLE","requested_outcome":"","needs_cognition":true,"clarification":"NOT_REQUIRED","selection_reason":"local lifeboat","capability_hints":["COGNITIVE_REASONING"]}`, FinishReason: cognitive.FinishCompleted}}
gateway := cognitiveGateway{
resolver: &registryStub{selected: selected},
providers: map[capability.ProviderKind][]providerRegistration{
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, Provider: primary}},
},
builtinLocal: &providerRegistration{Provider: lifeboat},
now: time.Now,
}
request := cognitive.GatewayInterpretationRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "сколько фигур в шахматах?", Locale: "ru", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, DeterministicSpeechAct: "QUESTION", DeterministicConfidence: 800, DeterministicApplicability: cognitive.InterpretationApplicable}
payload, _ := json.Marshal(request)
recorder := httptest.NewRecorder()
gateway.interpretationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/interpretations", bytes.NewReader(payload)))
if recorder.Code != http.StatusOK || !primary.called || !lifeboat.called {
t.Fatalf("status=%d primary=%t lifeboat=%t body=%s", recorder.Code, primary.called, lifeboat.called, recorder.Body.String())
}
var response cognitive.GatewayInterpretationResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.FinishReason != cognitive.FinishCompleted || response.Candidate.SelectionReason != "local lifeboat" {
t.Fatalf("response=%+v", response)
}
}
+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,6 +25,9 @@ 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, "Не повторяй вопрос пользователя") || !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, "красный цвет") {
t.Fatalf("review request did not carry compact personal memory: %+v", request.Context)
}
+79 -6
View File
@@ -799,6 +799,7 @@ func main() {
}
researchBacked := false
researchAttempted := false
stableKnowledgeReply := false
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
researchAttempted = true
if planID, idErr := ids.New(); idErr == nil {
@@ -810,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.
@@ -830,6 +835,7 @@ func main() {
cancelAnswer()
if answerErr == nil && stableKnowledgeAnswer(answer) {
reply, backed = answer, true
stableKnowledgeReply = true
}
}
cancelResearch()
@@ -860,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,
@@ -868,6 +875,7 @@ func main() {
cancelAnswer()
if answerErr == nil && stableKnowledgeAnswer(answer) {
planningReply = answer
stableKnowledgeReply = true
needsCognition = false
}
}
@@ -992,10 +1000,16 @@ 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, researchAttempted, researchBacked, reviewedReply, planningReply) {
} else if reviewMayReplacePlanningReply(stableSemanticReply, stableKnowledgeReply, researchAttempted, researchBacked, reviewedReply, planningReply) {
planningReply = reviewedReply
} else if reviewedReply != planningReply {
log.Printf("cognitive response review proposed stable semantic rewrite; preserving semantic reply")
@@ -1796,6 +1810,12 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc
excerpt = string([]rune(excerpt)[:limit]) + "…"
}
lines = append(lines, excerpt+"\nИсточник: "+researchSourceLabel(finding))
// A nearest-flight request needs one route-relevant schedule result.
// Appending weaker matches makes unrelated category pages look like
// alternatives even after the relevance check has accepted the route.
if nearestFlightQuery(query) {
break
}
if len(lines) == 2 {
break
}
@@ -1840,6 +1860,12 @@ 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 localPlaceSearchQuery(query) && !containsLocalPlaceEvidence(finding.Excerpt.Text) {
return false
}
if !nearestFlightQuery(query) {
return true
}
@@ -1869,6 +1895,31 @@ func researchFindingMatchesIntent(query string, finding epistemics.ResearchFindi
return true
}
func localPlaceSearchQuery(query string) bool {
lower := strings.ToLower(query)
return (strings.Contains(lower, "ресторан") || strings.Contains(lower, "кафе")) &&
(strings.Contains(lower, "адрес") || strings.Contains(lower, "отзыв") || strings.Contains(lower, "меню"))
}
func containsLocalPlaceEvidence(value string) bool {
lower := strings.ToLower(value)
for _, term := range []string{"ресторан", "кафе", "адрес", "меню", "отзыв", "кухн"} {
if strings.Contains(lower, term) {
return true
}
}
return false
}
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) != ""
}
@@ -1999,6 +2050,21 @@ 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.NeedsDurableWork {
return false
}
// ANSWER and COGNITION are reply-only help paths. They do not execute an
// external action, unlike direct capabilities, gaps or durable work.
if helpPath.Kind != work.HelpPathAnswer && helpPath.Kind != work.HelpPathCognition && helpPath.Kind != "" {
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://")
@@ -2012,7 +2078,14 @@ func reviewPreservesResearchEvidence(reviewed, draft string) bool {
// reviewMayReplacePlanningReply keeps ownership of a concrete capability
// result with that capability. A review may improve source-backed research,
// but must not turn an honest no-result into an invented clarification.
func reviewMayReplacePlanningReply(stableSemanticReply, researchAttempted, researchBacked bool, reviewed, draft string) bool {
func reviewMayReplacePlanningReply(stableSemanticReply, stableKnowledgeReply, researchAttempted, researchBacked bool, reviewed, draft string) bool {
// A stable-knowledge answer is a model-produced draft rather than an owned
// capability result. It therefore goes through a second cognitive pass for
// conversational quality, while identity, memory, media and action outcomes
// remain immutable at this boundary.
if stableKnowledgeReply {
return true
}
if stableSemanticReply {
return false
}
+76 -4
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"
)
@@ -46,6 +47,17 @@ func TestNearestFlightReplyFiltersUnrelatedSearchSnippets(t *testing.T) {
}
}
func TestNearestFlightReplyDoesNotAppendWeakerSecondMatch(t *testing.T) {
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{
{State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://schedule.example", Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание авиарейсов Москва — Казань, время вылета и прилёта."}},
{State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://category.example", Title: "Категория направлений"}, Excerpt: epistemics.SourceExcerpt{Text: "Список близлежащих городов и ориентировочные цены."}},
}}
reply, backed := renderResearchReply("ближайшие авиарейсы Москва Казань сегодня", result, nil)
if !backed || !strings.Contains(reply, "Рейсы Москва") || strings.Contains(reply, "Категория направлений") {
t.Fatalf("weaker second finding leaked into nearest-flight reply: %q", reply)
}
}
func TestNearestFlightEvidenceRequiresBothRouteEndpointsWhenKnown(t *testing.T) {
query := "ближайшие авиарейсы Москва Казань сегодня"
matched := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание авиарейсов из Москвы в Казань."}}
@@ -70,6 +82,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,
@@ -82,6 +106,18 @@ func TestResearchReplyRejectsGenericLandingPageForConcreteIdentifier(t *testing.
}
}
func TestLocalPlaceReplyRejectsDictionaryFinding(t *testing.T) {
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
State: epistemics.FindingSupported,
Source: epistemics.Source{Locator: "https://dictionary.example", Title: "Ближайший — словарь"},
Excerpt: epistemics.SourceExcerpt{Text: "Ближайший — значение слова и пример употребления."},
}}}
reply, backed := renderResearchReply("места ресторан в Сочи адрес отзывы меню", result, nil)
if backed || !strings.Contains(reply, "Проверяемых данных") {
t.Fatalf("dictionary finding was presented as local place result: %q", reply)
}
}
func TestResearchReplyKeepsFindingContainingConcreteIdentifier(t *testing.T) {
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
State: epistemics.FindingSupported,
@@ -101,19 +137,55 @@ 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, true, false, "Уточните аэропорт.", "Пока не смог получить проверяемый ответ.") {
if reviewMayReplacePlanningReply(false, false, true, false, "Уточните аэропорт.", "Пока не смог получить проверяемый ответ.") {
t.Fatal("review replaced an honest unbacked research outcome")
}
if !reviewMayReplacePlanningReply(false, true, true, "Расписание. Источник: https://example.test", "Расписание. Источник: https://example.test") {
if !reviewMayReplacePlanningReply(false, false, true, true, "Расписание. Источник: https://example.test", "Расписание. Источник: https://example.test") {
t.Fatal("review should be able to improve a source-backed research outcome")
}
if reviewMayReplacePlanningReply(false, true, true, "Расписание без ссылки", "Расписание. Источник: https://example.test") {
if reviewMayReplacePlanningReply(false, false, true, true, "Расписание без ссылки", "Расписание. Источник: https://example.test") {
t.Fatal("review dropped the research evidence")
}
if !reviewMayReplacePlanningReply(false, false, false, "Нормальный ответ", "Черновик") {
if !reviewMayReplacePlanningReply(false, false, false, false, "Нормальный ответ", "Черновик") {
t.Fatal("ordinary conversation review was unexpectedly blocked")
}
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, nil, work.HelpPath{Kind: work.HelpPathCognition}, false) {
t.Fatal("reply-only cognition path was incorrectly rejected")
}
if shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{Kind: work.HelpPathDirectCapability}, false) {
t.Fatal("direct capability was incorrectly replaced by a textual 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 {
@@ -189,6 +189,13 @@ func TestTravelResearchQueryUsesKnownOriginForDistance(t *testing.T) {
}
}
func TestTravelResearchQueryCompilesLocalPlaceSearchWithoutSearchImperative(t *testing.T) {
query := TravelResearchQuery("найди ближайший ресторан в Сочи", nil, time.Time{})
if strings.Contains(query, "найди") || strings.Contains(query, "ближайший") || !strings.Contains(query, "сочи") || !strings.Contains(query, "адрес") {
t.Fatalf("local place query was not compiled semantically: %q", query)
}
}
func TestTravelRouteWithKnownOriginDoesNotAskForTicketDate(t *testing.T) {
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
if got := TravelSearchClarification("как можно добраться до Сочи?", history); got != "" {
+20 -1
View File
@@ -46,7 +46,9 @@ func normalizedTravelSearchText(message string) string {
func TravelResearchQuery(message string, history []interaction.HumanUtterance, now time.Time) string {
query := TravelSearchQuery(message, now)
tokens := semanticTokens(StripAssistantVocative(message))
if containsStemInTokens(tokens, "расстоян", "километр") {
if localPlaceSearchRequest(tokens) {
return localPlaceResearchQuery(tokens)
} else if containsStemInTokens(tokens, "расстоян", "километр") {
origin := travelContextOrigin(history)
destination := travelContextDestinationFromTokens(tokens)
if origin != "" && destination != "" {
@@ -86,6 +88,23 @@ func TravelResearchQuery(message string, history []interaction.HumanUtterance, n
return query
}
// localPlaceSearchRequest recognizes a proximity search as a local-search
// capability, keeping it separate from travel-route compilation.
func localPlaceSearchRequest(tokens []string) bool {
return containsStemInTokens(tokens, "ресторан", "кафе") && containsStemInTokens(tokens, "ближ", "рядом")
}
func localPlaceResearchQuery(tokens []string) string {
parts := make([]string, 0, len(tokens))
for _, token := range tokens {
if containsStem(token, "найд", "покаж", "ближ", "рядом") {
continue
}
parts = append(parts, token)
}
return "места " + strings.Join(parts, " ") + " адрес отзывы меню"
}
// trainScheduleResearchQuery formulates an observed schedule request from the
// user's route and remembered origin. It contains no city, provider or answer
// catalogue: actual departure data remains evidence from the selected source.