Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbecadcec2 | ||
|
|
1c89b30512 | ||
|
|
75a8533939 | ||
|
|
515a98c470 | ||
|
|
d3ad1fd5bb | ||
|
|
e1802aef12 | ||
|
|
35fc2438dc | ||
|
|
70335a78e2 | ||
|
|
41067a0b16 | ||
|
|
fba05d5ed4 | ||
|
|
6e14ba1727 | ||
|
|
9c6e2097b1 | ||
|
|
96559f6ab8 |
@@ -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: ®istryStub{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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -868,6 +874,7 @@ func main() {
|
||||
cancelAnswer()
|
||||
if answerErr == nil && stableKnowledgeAnswer(answer) {
|
||||
planningReply = answer
|
||||
stableKnowledgeReply = true
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
@@ -992,10 +999,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")
|
||||
@@ -1840,6 +1853,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
|
||||
}
|
||||
@@ -1869,6 +1885,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) != ""
|
||||
}
|
||||
@@ -2012,7 +2037,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
|
||||
}
|
||||
|
||||
@@ -70,6 +70,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,19 +113,36 @@ 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 TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user