Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3ad1fd5bb | ||
|
|
e1802aef12 | ||
|
|
35fc2438dc | ||
|
|
70335a78e2 | ||
|
|
41067a0b16 | ||
|
|
fba05d5ed4 | ||
|
|
6e14ba1727 | ||
|
|
9c6e2097b1 | ||
|
|
96559f6ab8 | ||
|
|
46b89577eb | ||
|
|
a6ddbfaaf2 | ||
|
|
362a1bd993 | ||
|
|
735222de0d | ||
|
|
4e3d153557 | ||
|
|
f316f7ec3f | ||
|
|
da5d06895b | ||
|
|
4d1a579132 | ||
|
|
43836af511 | ||
|
|
32457f7142 | ||
|
|
204eee489c | ||
|
|
5cd8665aba | ||
|
|
04018cbd07 | ||
|
|
23a1eeae56 | ||
|
|
369588ada7 | ||
|
|
e323689fd4 | ||
|
|
af042dc69f | ||
|
|
60c1c31bd9 | ||
|
|
ff3f14fc77 | ||
|
|
36f991d8d0 | ||
|
|
54a3996c17 | ||
|
|
a769f33d16 | ||
|
|
056ff1b795 | ||
|
|
7b2debb60e | ||
|
|
6dfb3fc26c | ||
|
|
e0a093eaf3 | ||
|
|
24916aa8f7 | ||
|
|
d7afd5be34 | ||
|
|
e104ce790d | ||
|
|
d7484cf757 | ||
|
|
a57e8ea71b | ||
|
|
9dffde63b8 | ||
|
|
4e5bb9abc9 | ||
|
|
3435de8013 | ||
|
|
76c2824887 | ||
|
|
fc00a57942 | ||
|
|
1b21888e8b | ||
|
|
012f5efafe | ||
|
|
eb842e24ff | ||
|
|
13bbcdf747 | ||
|
|
c00b9b1549 | ||
|
|
2d88d488f4 | ||
|
|
72c9cfc893 | ||
|
|
0b619bdc08 | ||
|
|
db23e984da | ||
|
|
f0c8f39986 | ||
|
|
5d8ec1995a | ||
|
|
75d707e960 | ||
|
|
89b053b7bf | ||
|
|
e90aded6d3 | ||
|
|
8456ecd237 | ||
|
|
b89fdef1d9 | ||
|
|
6f3d576602 | ||
|
|
b026ec151e | ||
|
|
5d4c3877b1 | ||
|
|
113c5ab965 | ||
|
|
bce7993a6a | ||
|
|
3bbf416c20 | ||
|
|
b62b3b0dc3 | ||
|
|
7e72474b0a | ||
|
|
b05081328e | ||
|
|
0e7d1a7a50 | ||
|
|
2aaa73bb2a | ||
|
|
9000d90d72 | ||
|
|
4afcfd3846 | ||
|
|
6ba8265686 | ||
|
|
21583528c0 | ||
|
|
b1bb48f5b5 | ||
|
|
88f5f6e8f8 | ||
|
|
5439db2cc3 | ||
|
|
208cb18ee0 | ||
|
|
5d19becdaf | ||
|
|
4cd3ecdf15 | ||
|
|
8e26f529f7 | ||
|
|
3cc636d6b7 | ||
|
|
14dc439c82 | ||
|
|
21887988b2 | ||
|
|
232a57eed4 | ||
|
|
d1fd70e436 | ||
|
|
1d73162df1 | ||
|
|
7788cf4b44 | ||
|
|
d9dcddbdbb | ||
|
|
5fb616310d | ||
|
|
5a9cf7a22d | ||
|
|
d649f79ca4 |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -38,7 +39,8 @@ func (g multimodalGateway) handler(operation capability.MultimodalOperation) htt
|
||||
http.Error(w, "local image generation capability unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
image, err := g.imageGenerator.Generate(r.Context(), g.imageModel, r.FormValue("prompt"), r.FormValue("size"))
|
||||
prompt := r.FormValue("prompt")
|
||||
image, err := g.imageGenerator.Generate(r.Context(), g.imageModel, prompt, imageRequestSize(prompt, r.FormValue("size")))
|
||||
if err != nil || strings.TrimSpace(image.DataBase64) == "" {
|
||||
logMultimodalFailure(operation, err)
|
||||
http.Error(w, "local image generation capability unavailable", http.StatusBadGateway)
|
||||
@@ -96,6 +98,40 @@ func (g multimodalGateway) handler(operation capability.MultimodalOperation) htt
|
||||
}
|
||||
}
|
||||
|
||||
// imageRequestSize is a transport-level normalization for the local image
|
||||
// capability. An explicit, valid size always wins. When the caller only
|
||||
// expresses a conventional output-quality target, the capability translates it
|
||||
// to a supported provider size without exposing provider workflow details to
|
||||
// the interaction core. This policy is deliberately local and replaceable
|
||||
// with the image capability implementation.
|
||||
func imageRequestSize(prompt, requested string) string {
|
||||
if isImageSize(requested) {
|
||||
return strings.TrimSpace(requested)
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(prompt))
|
||||
for index, word := range words {
|
||||
switch word {
|
||||
case "4k", "uhd":
|
||||
return "2048x2048"
|
||||
case "hd":
|
||||
return "1024x1024"
|
||||
case "ultra":
|
||||
if index+1 < len(words) && words[index+1] == "hd" {
|
||||
return "2048x2048"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isImageSize(value string) bool {
|
||||
var width, height int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(value), "%dx%d", &width, &height); err != nil {
|
||||
return false
|
||||
}
|
||||
return width >= 256 && height >= 256 && width <= 2048 && height <= 2048
|
||||
}
|
||||
|
||||
func logMultimodalFailure(operation capability.MultimodalOperation, err error) {
|
||||
// Keep provider details in server diagnostics; never return them to chat.
|
||||
log.Printf("multimodal operation %s failed: %v", operation, err)
|
||||
|
||||
@@ -47,6 +47,27 @@ func TestMultimodalImageGenerationFailsClosedWhenSkillIsUnbound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRequestSizeUsesExplicitSizeOrQualityTarget(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
prompt string
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{name: "explicit", prompt: "нарисуй птицу", requested: "768x512", want: "768x512"},
|
||||
{name: "hd", prompt: "нарисуй птицу в HD", want: "1024x1024"},
|
||||
{name: "uhd", prompt: "draw a bird in UHD", want: "2048x2048"},
|
||||
{name: "ordinary", prompt: "нарисуй птицу", want: ""},
|
||||
{name: "invalid explicit", prompt: "нарисуй птицу", requested: "100x100", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := imageRequestSize(test.prompt, test.requested); got != test.want {
|
||||
t.Fatalf("imageRequestSize(%q, %q) = %q, want %q", test.prompt, test.requested, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultimodalVideoUnderstandingFailsClosedWhenSkillIsUnbound(t *testing.T) {
|
||||
server := multimodalGateway{now: time.Now}
|
||||
request := multipartRequest(t, "/v1/multimodal/video-understand", "clip.mp4", []byte("video"))
|
||||
|
||||
@@ -72,3 +72,11 @@ func hasChatRoute(routes []chatRoute, kind chatRouteKind) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// specializedRouteBypassesGeneralInterpretation keeps a concrete capability
|
||||
// route stable. General cognition may still be used later to assess evidence,
|
||||
// but it must not replace an executable research or media route with a vague
|
||||
// conversational clarification before the capability is attempted.
|
||||
func specializedRouteBypassesGeneralInterpretation(routes []chatRoute) bool {
|
||||
return hasChatRoute(routes, chatRouteMedia) || hasChatRoute(routes, chatRouteResearch)
|
||||
}
|
||||
|
||||
@@ -39,3 +39,15 @@ func TestBuildChatRoutePlanAlwaysKeepsLocalConversationRoute(t *testing.T) {
|
||||
t.Fatalf("unexpected local route: %+v", planned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecializedRouteBypassesGeneralInterpretation(t *testing.T) {
|
||||
if !specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteResearch}}) {
|
||||
t.Fatal("research route must execute before general cognition")
|
||||
}
|
||||
if !specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteMedia}}) {
|
||||
t.Fatal("media route must execute before general cognition")
|
||||
}
|
||||
if specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteConversation}}) {
|
||||
t.Fatal("conversation-only route must retain general cognition")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +31,23 @@ type cognitiveInterpretationClient struct {
|
||||
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
|
||||
// into a natural answer. The draft remains the safe fallback when cognition is
|
||||
// unavailable; the model does not mutate canonical state or execute actions.
|
||||
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft, personalMemory string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
requestID, err := ids.New()
|
||||
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})
|
||||
}
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
|
||||
Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Context: context, Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{
|
||||
SpeechAct: "RESPONSE_REVIEW",
|
||||
CapabilityHints: meaning.CapabilityHints,
|
||||
@@ -78,6 +82,54 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
|
||||
return strings.TrimSpace(result.AdvisoryText), nil
|
||||
}
|
||||
|
||||
// AnswerStableKnowledge asks the local cognitive route for a short answer to a
|
||||
// non-current question only after evidence retrieval did not produce a usable
|
||||
// result. The model decides whether its knowledge is stable enough; it must
|
||||
// explicitly decline changing or source-dependent facts instead of guessing.
|
||||
func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
requestID, err := ids.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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})
|
||||
}
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: 1024, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayAdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" {
|
||||
return "", fmt.Errorf("cognitive stable knowledge incomplete: %s", result.FinishReason)
|
||||
}
|
||||
return strings.TrimSpace(result.AdvisoryText), nil
|
||||
}
|
||||
|
||||
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
|
||||
@@ -25,6 +25,12 @@ 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)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Меня зовут Дед. Я Домовой.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
@@ -33,7 +39,7 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
|
||||
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", "Предпочтение пользователя (его сообщение): красный цвет", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -42,6 +48,28 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerStableKnowledgeUsesLocalOnlyCognitiveRoute(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode stable knowledge request: %v", err)
|
||||
}
|
||||
if request.DataPolicy != capability.DataPolicyLocalOnly || request.Meaning.SpeechAct != "STABLE_KNOWLEDGE_ANSWER" {
|
||||
t.Fatalf("unexpected stable knowledge request: %+v", request)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Санкт-Петербург — город в России.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newCognitiveInterpretationClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := client.AnswerStableKnowledge(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "Питер — это где?", "", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
|
||||
if err != nil || answer != "Санкт-Петербург — город в России." {
|
||||
t.Fatalf("answer=%q err=%v", answer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestSpecializedInformationReplyIsNotOverwrittenByResearch(t *testing.T) {
|
||||
for _, kind := range []interaction.InformationRequestKind{
|
||||
interaction.InformationRequestMemoryInventory,
|
||||
interaction.InformationRequestCapabilityInventory,
|
||||
interaction.InformationRequestCognitiveModelInventory,
|
||||
interaction.InformationRequestCognitiveChannelStatus,
|
||||
interaction.InformationRequestCognitiveRecovery,
|
||||
} {
|
||||
if shouldRunGeneralResearch(kind) {
|
||||
t.Fatalf("specialized information request %q must bypass general research", kind)
|
||||
}
|
||||
}
|
||||
if !shouldRunGeneralResearch(interaction.InformationRequestTimeDifference) {
|
||||
t.Fatal("time difference must use the current-data research route")
|
||||
}
|
||||
if !shouldRunGeneralResearch("") {
|
||||
t.Fatal("ordinary research requests must still use general research")
|
||||
}
|
||||
}
|
||||
+300
-36
@@ -120,7 +120,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 12 * time.Second})
|
||||
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 20 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -511,6 +511,7 @@ func main() {
|
||||
}
|
||||
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
creativeUserRequest := interactiondomain.IsCreativeRequest(utterance.Content)
|
||||
locationReply := interactiondomain.LocationContextReply(utterance.Content)
|
||||
continuationMessage := ""
|
||||
travelContinuation := false
|
||||
var previousTask *interaction.HumanUtterance
|
||||
@@ -532,7 +533,8 @@ func main() {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
if !creativeUserRequest {
|
||||
personalMemory := interactiondomain.CompilePersonalMemory(conversationHistory)
|
||||
if !creativeUserRequest && locationReply == "" {
|
||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
effectiveTaskMessage = continuation
|
||||
@@ -540,6 +542,10 @@ func main() {
|
||||
continuationMessage = continuation
|
||||
travelContinuation = true
|
||||
effectiveTaskMessage = continuation
|
||||
} else if continuation := interactiondomain.ContinueTravelDate(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
travelContinuation = true
|
||||
effectiveTaskMessage = continuation
|
||||
}
|
||||
}
|
||||
if request.ConversationID != "" && interactiondomain.IsRetryRequest(utterance.Content) {
|
||||
@@ -566,7 +572,11 @@ func main() {
|
||||
continuedSemantic, continuedErr := interactiondomain.InterpretUtterance(continuedUtterance, projects, previousFocus, now)
|
||||
if continuedErr == nil {
|
||||
continuedSemantic.Interpretation.Source = "CONVERSATION_CONTINUATION"
|
||||
continuedSemantic.Interpretation.SelectionReason = "location follow-up completed the previous local-search task"
|
||||
if travelContinuation {
|
||||
continuedSemantic.Interpretation.SelectionReason = "travel follow-up completed the previous travel task"
|
||||
} else {
|
||||
continuedSemantic.Interpretation.SelectionReason = "location follow-up completed the previous local-search task"
|
||||
}
|
||||
semantic = continuedSemantic
|
||||
}
|
||||
}
|
||||
@@ -599,8 +609,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
locationReply := interactiondomain.LocationContextReply(utterance.Content)
|
||||
if locationReply != "" {
|
||||
continuationMessage = ""
|
||||
travelContinuation = false
|
||||
effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
previousTask = nil
|
||||
semantic.Reply = locationReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
@@ -616,6 +629,7 @@ func main() {
|
||||
assistantName = latestAssistantName(conversationHistory)
|
||||
}
|
||||
identityReply := ""
|
||||
memoryInstructionReply := ""
|
||||
log.Printf("interaction identity context: name=%q history=%d", assistantName, len(conversationHistory))
|
||||
if interactiondomain.IsIdentityInquiry(utterance.Content) && assistantName != "" {
|
||||
identityReply = "Меня зовут " + assistantName + ". Я Домовой, ваш помощник."
|
||||
@@ -625,6 +639,14 @@ func main() {
|
||||
semantic.Interpretation.Applicability = interaction.SemanticApplicable
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationNotRequired
|
||||
}
|
||||
if identityReply == "" && interactiondomain.IsExplicitMemoryInstruction(utterance.Content) {
|
||||
memoryInstructionReply = "Запомнил."
|
||||
semantic.Reply = memoryInstructionReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.CapabilityHints = nil
|
||||
semantic.Interpretation.Applicability = interaction.SemanticApplicable
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationNotRequired
|
||||
}
|
||||
deterministicSpeechAct := semantic.Interpretation.SpeechAct
|
||||
deterministicReply := semantic.Reply
|
||||
routePlan := buildChatRoutePlan(semantic)
|
||||
@@ -632,14 +654,14 @@ func main() {
|
||||
cognitiveAvailable := true
|
||||
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
|
||||
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
|
||||
// A selected specialized media route already has an executable semantic
|
||||
// outcome. Do not block it on the general cognitive channel: image
|
||||
// generation must reach its own adapter even when cognition is degraded.
|
||||
if hasChatRoute(routePlan, chatRouteMedia) {
|
||||
// A selected specialized route already has an executable semantic outcome.
|
||||
// Do not block it on the general cognitive channel: research and image
|
||||
// generation must reach their own adapters even when cognition is degraded.
|
||||
if specializedRouteBypassesGeneralInterpretation(routePlan) {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if identityReply != "" {
|
||||
if identityReply != "" || memoryInstructionReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if semanticInterpretationRequested || semanticReflexAudit {
|
||||
@@ -748,8 +770,8 @@ func main() {
|
||||
case interaction.InformationRequestMemoryInventory:
|
||||
planningReply = humanMemoryInventory(conversationHistory, assistantName)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestTimeDifference:
|
||||
planningReply = humanTimeDifferenceReply(effectiveTaskMessage)
|
||||
case interaction.InformationRequestResearchFailureExplanation:
|
||||
planningReply = "Я не выдаю предположение как факт, если не получил согласованных проверяемых данных. Могу повторить поиск, уточнить запрос или проверить другой источник."
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||
@@ -759,17 +781,27 @@ func main() {
|
||||
planningReply = "Я умею общаться, сохранять контекст, искать проверяемую информацию и работать с подключёнными моделями. Дополнительные действия подключаются отдельно."
|
||||
}
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestLocalCapabilityInventory:
|
||||
planningReply = "Без внешних моделей я могу поддерживать разговор, хранить контекст, вести цели и напоминания, а также честно сообщать состояние подключённых возможностей."
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
candidates, inventoryErr := capabilityRepository.LoadCognitiveCandidates(r.Context(), capability.CapabilityID("creator-advisory"), session.PrincipalID)
|
||||
planningReply = humanCognitiveModelInventory(candidates, inventoryErr)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveCurrentModel:
|
||||
health, healthErr := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID("cognitive-proposal"))
|
||||
planningReply = humanCurrentCognitiveModelReply(health, healthErr)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery:
|
||||
health, healthErr := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID("cognitive-proposal"))
|
||||
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
|
||||
needsCognition = false
|
||||
}
|
||||
researchBacked := false
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
|
||||
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 {
|
||||
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||
replies := make([]string, 0, len(parts))
|
||||
@@ -778,14 +810,35 @@ func main() {
|
||||
replies = append(replies, "По запросу «"+part+"»: "+clarification)
|
||||
continue
|
||||
}
|
||||
searchQuery := interactiondomain.TravelSearchQuery(part, now)
|
||||
researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
learnedLexicon := interactiondomain.TaskLexiconFromHistory(conversationHistory)
|
||||
// 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)
|
||||
// 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.
|
||||
researchCtx, cancelResearch := context.WithTimeout(r.Context(), 18*time.Second)
|
||||
result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{
|
||||
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: searchQuery, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
|
||||
PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: searchQuery,
|
||||
})
|
||||
reply, backed := renderResearchReply(searchQuery, result, researchErr)
|
||||
if !backed && cognitiveAvailable {
|
||||
answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, part, personalMemory.ContextText(), cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
cancelAnswer()
|
||||
if answerErr == nil && stableKnowledgeAnswer(answer) {
|
||||
reply, backed = answer, true
|
||||
stableKnowledgeReply = true
|
||||
}
|
||||
}
|
||||
cancelResearch()
|
||||
reply, backed := renderResearchReply(part, result, researchErr)
|
||||
if backed {
|
||||
researchBacked = true
|
||||
}
|
||||
@@ -813,6 +866,18 @@ func main() {
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion && !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,
|
||||
})
|
||||
cancelAnswer()
|
||||
if answerErr == nil && stableKnowledgeAnswer(answer) {
|
||||
planningReply = answer
|
||||
stableKnowledgeReply = true
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
||||
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
||||
needsCognition = false
|
||||
@@ -920,9 +985,13 @@ func main() {
|
||||
planningReply = identityReply
|
||||
needsCognition = false
|
||||
}
|
||||
if memoryInstructionReply != "" {
|
||||
planningReply = memoryInstructionReply
|
||||
needsCognition = false
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, cognitive.TaskMeaning{
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, personalMemory.ContextText(), cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
@@ -930,10 +999,10 @@ func main() {
|
||||
ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
cancelReview()
|
||||
stableSemanticReply := mediaAttempted || identityReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
|
||||
stableSemanticReply := mediaAttempted || identityReply != "" || memoryInstructionReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
|
||||
if reviewErr != nil {
|
||||
log.Printf("cognitive response review unavailable: %v", reviewErr)
|
||||
} else if !stableSemanticReply && (!researchBacked || reviewPreservesResearchEvidence(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")
|
||||
@@ -955,6 +1024,11 @@ func main() {
|
||||
needsCognition = false
|
||||
planningReply = identityReply
|
||||
}
|
||||
if memoryInstructionReply != "" {
|
||||
semantic.Reply = memoryInstructionReply
|
||||
needsCognition = false
|
||||
planningReply = memoryInstructionReply
|
||||
}
|
||||
if creativeReply != "" {
|
||||
semantic.Reply = creativeReply
|
||||
needsCognition = false
|
||||
@@ -1589,6 +1663,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRunGeneralResearch(kind interaction.InformationRequestKind) bool {
|
||||
return kind == "" || kind == interaction.InformationRequestTimeDifference
|
||||
}
|
||||
|
||||
func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword string, sessionKey []byte, now func() time.Time) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
authenticated := identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r))
|
||||
@@ -1709,13 +1787,16 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc
|
||||
return "Сейчас не удалось получить данные из подключённого источника. Попробую снова через доступный маршрут.", false
|
||||
}
|
||||
lines := make([]string, 0, 2)
|
||||
for index, finding := range result.Findings {
|
||||
for _, finding := range result.Findings {
|
||||
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
|
||||
continue
|
||||
}
|
||||
if !researchFindingMatchesIntent(query, finding) {
|
||||
continue
|
||||
}
|
||||
excerpt := humanResearchExcerpt(query, finding.Excerpt.Text)
|
||||
limit := 180
|
||||
if index > 0 {
|
||||
if len(lines) > 0 {
|
||||
limit = 100
|
||||
}
|
||||
if len([]rune(excerpt)) > limit {
|
||||
@@ -1727,17 +1808,27 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
if identifiers := requestedResearchIdentifiers(query); len(identifiers) > 0 {
|
||||
return "Не нашёл проверяемых данных о «" + strings.Join(identifiers, "», «") + "». Возможно, название написано иначе; пришлите ссылку или уточните написание, и я проверю.", false
|
||||
}
|
||||
return "Проверяемых данных пока не найдено. Могу повторить поиск или уточнить, что именно проверить.", false
|
||||
}
|
||||
if isInternetStatusQuery(query) {
|
||||
return "Интернет работает: Домовой получил ответ от внешнего источника.", true
|
||||
}
|
||||
if nearestFlightQuery(query) && !containsFlightTimeEvidence(strings.Join(lines, " ")) {
|
||||
return "Нашёл расписание по маршруту, но точное время ближайшего вылета в доступных фрагментах не указано. Откройте источник и выберите ближайший рейс:\n\n" + strings.Join(lines, "\n\n"), true
|
||||
}
|
||||
reply := "Нашёл проверяемые данные:\n\n" + strings.Join(lines, "\n\n")
|
||||
lowerQuery := strings.ToLower(query)
|
||||
if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) {
|
||||
reply = "Актуальная цена в найденных фрагментах не указана, поэтому не буду придумывать сумму. Вот страницы, где её можно проверить:\n" + reply
|
||||
if trainScheduleQuery(query) && !containsScheduleTimeEvidence(strings.Join(lines, " ")) {
|
||||
return "Нашёл страницу расписания по маршруту, но в доступных фрагментах нет точного времени отправления:\n\n" + strings.Join(lines, "\n\n"), true
|
||||
}
|
||||
return reply, true
|
||||
return "Нашёл проверяемые данные:\n\n" + strings.Join(lines, "\n\n"), true
|
||||
}
|
||||
|
||||
func isInternetStatusQuery(query string) bool {
|
||||
lower := strings.ToLower(query)
|
||||
return (strings.Contains(lower, "интернет") || strings.Contains(lower, "сет")) &&
|
||||
(strings.Contains(lower, "работает") || strings.Contains(lower, "доступ") || strings.Contains(lower, "проверь") || strings.Contains(lower, "связ"))
|
||||
}
|
||||
|
||||
func nearestFlightQuery(query string) bool {
|
||||
@@ -1745,6 +1836,139 @@ func nearestFlightQuery(query string) bool {
|
||||
return strings.Contains(lower, "ближ") && (strings.Contains(lower, "рейс") || strings.Contains(lower, "самолет") || strings.Contains(lower, "самолёт") || strings.Contains(lower, "вылет"))
|
||||
}
|
||||
|
||||
// researchFindingMatchesIntent is a final relevance check over evidence
|
||||
// already obtained by the read-only research route. It prevents unrelated
|
||||
// search-engine snippets from becoming a human-facing answer when a search
|
||||
// term has a common non-travel meaning.
|
||||
func researchFindingMatchesIntent(query string, finding epistemics.ResearchFinding) bool {
|
||||
if !findingContainsRequestedIdentifier(query, finding) {
|
||||
return false
|
||||
}
|
||||
if ticketSearchQuery(query) && !ticketFindingMatchesDestination(query, finding) {
|
||||
return false
|
||||
}
|
||||
if !nearestFlightQuery(query) {
|
||||
return true
|
||||
}
|
||||
displayedExcerpt := strings.ToLower(humanResearchExcerpt(query, finding.Excerpt.Text))
|
||||
travelEvidence := strings.Contains(displayedExcerpt, "рейс") ||
|
||||
strings.Contains(displayedExcerpt, "вылет") ||
|
||||
strings.Contains(displayedExcerpt, "авиабил") ||
|
||||
strings.Contains(displayedExcerpt, "аэропорт") ||
|
||||
strings.Contains(displayedExcerpt, "самолет") ||
|
||||
strings.Contains(displayedExcerpt, "самолёт") ||
|
||||
strings.Contains(displayedExcerpt, "расписан")
|
||||
if !travelEvidence {
|
||||
return false
|
||||
}
|
||||
routeTerms := nearestFlightRouteTerms(query)
|
||||
if len(routeTerms) < 2 {
|
||||
return true
|
||||
}
|
||||
// A route-looking URL or page title alone is not enough for a human-facing
|
||||
// answer: the displayed excerpt itself must support both endpoints.
|
||||
excerptEvidence := strings.ToLower(finding.Excerpt.Text)
|
||||
for _, term := range routeTerms {
|
||||
if !strings.Contains(excerptEvidence, term) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ticketSearchQuery(query string) bool {
|
||||
return interactiondomain.TicketSearchDestination(query) != ""
|
||||
}
|
||||
|
||||
func ticketFindingMatchesDestination(query string, finding epistemics.ResearchFinding) bool {
|
||||
destination := strings.ToLower(interactiondomain.TicketSearchDestination(query))
|
||||
if destination == "" {
|
||||
return true
|
||||
}
|
||||
// The source title or URL may describe a category; human-facing proof must
|
||||
// mention the requested destination in the actual displayed excerpt.
|
||||
return strings.Contains(strings.ToLower(finding.Excerpt.Text), destination)
|
||||
}
|
||||
|
||||
// findingContainsRequestedIdentifier prevents a generic landing page from
|
||||
// being presented as a result for a concrete model, article or brand request.
|
||||
// Identifiers are detected by their syntax, not by a catalogue of providers or
|
||||
// entities, so the rule stays valid as user vocabulary changes.
|
||||
func findingContainsRequestedIdentifier(query string, finding epistemics.ResearchFinding) bool {
|
||||
identifiers := requestedResearchIdentifiers(query)
|
||||
if len(identifiers) == 0 {
|
||||
return true
|
||||
}
|
||||
evidence := strings.ToLower(strings.Join([]string{finding.Source.Title, finding.Source.Locator, finding.Excerpt.Text}, " "))
|
||||
for _, identifier := range identifiers {
|
||||
if !strings.Contains(evidence, strings.ToLower(identifier)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requestedResearchIdentifiers(query string) []string {
|
||||
rawIdentifiers := regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{3,}`).FindAllString(query, -1)
|
||||
identifiers := make([]string, 0, len(rawIdentifiers))
|
||||
exact := make([]string, 0, len(rawIdentifiers))
|
||||
for _, identifier := range rawIdentifiers {
|
||||
if researchDateIdentifier(identifier) {
|
||||
continue
|
||||
}
|
||||
identifiers = append(identifiers, identifier)
|
||||
if strings.ContainsAny(identifier, "._-") || strings.IndexFunc(identifier, func(r rune) bool { return r >= '0' && r <= '9' }) >= 0 {
|
||||
exact = append(exact, identifier)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact
|
||||
}
|
||||
return identifiers
|
||||
}
|
||||
|
||||
func researchDateIdentifier(value string) bool {
|
||||
return regexp.MustCompile(`^\d{1,2}[.-]\d{1,2}[.-]\d{2,4}$`).MatchString(value) ||
|
||||
regexp.MustCompile(`^\d{4}[.-]\d{1,2}[.-]\d{1,2}$`).MatchString(value)
|
||||
}
|
||||
|
||||
// nearestFlightRouteTerms retains only the two endpoint-shaped terms from a
|
||||
// structured flight search. It has no city or provider vocabulary: endpoint
|
||||
// relevance is determined from the request itself.
|
||||
func nearestFlightRouteTerms(query string) []string {
|
||||
fields := regexp.MustCompile(`[\p{L}]{4,}`).FindAllString(strings.ToLower(query), -1)
|
||||
ignored := []string{"ближай", "авиарейс", "сегодня", "самолет", "самолёт", "вылет", "рейс", "пункт", "отправлен"}
|
||||
terms := make([]string, 0, 2)
|
||||
for _, field := range fields {
|
||||
skip := false
|
||||
for _, prefix := range ignored {
|
||||
if strings.HasPrefix(field, prefix) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
prefixLength := 4
|
||||
if len([]rune(field)) < prefixLength {
|
||||
prefixLength = len([]rune(field))
|
||||
}
|
||||
term := string([]rune(field)[:prefixLength])
|
||||
already := false
|
||||
for _, current := range terms {
|
||||
if current == term {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
func containsFlightTimeEvidence(value string) bool {
|
||||
return regexp.MustCompile(`(?m)\b(?:[01]?\d|2[0-3]):[0-5]\d\b`).MatchString(value)
|
||||
}
|
||||
@@ -1769,9 +1993,17 @@ func humanResearchExcerpt(query, raw string) string {
|
||||
return text
|
||||
}
|
||||
|
||||
func containsPriceEvidence(value string) bool {
|
||||
pricePattern := regexp.MustCompile(`(?i)(\d[\d\s.,]{2,})(?:\s*(?:₽|руб\.?|р\.?|usd|\$|€))`)
|
||||
return pricePattern.MatchString(value)
|
||||
func trainScheduleQuery(query string) bool {
|
||||
lower := strings.ToLower(query)
|
||||
return strings.Contains(lower, "поезд") && strings.Contains(lower, "время отправления")
|
||||
}
|
||||
|
||||
func containsScheduleTimeEvidence(value string) bool {
|
||||
return regexp.MustCompile(`\b(?:[01]?\d|2[0-3]):[0-5]\d\b`).MatchString(value)
|
||||
}
|
||||
|
||||
func stableKnowledgeAnswer(answer string) bool {
|
||||
return strings.TrimSpace(answer) != "" && strings.TrimSpace(answer) != "NEEDS_VERIFIED_RESEARCH"
|
||||
}
|
||||
|
||||
func reviewPreservesResearchEvidence(reviewed, draft string) bool {
|
||||
@@ -1784,6 +2016,26 @@ func reviewPreservesResearchEvidence(reviewed, draft string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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, 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
|
||||
}
|
||||
if !researchAttempted {
|
||||
return true
|
||||
}
|
||||
return researchBacked && reviewPreservesResearchEvidence(reviewed, draft)
|
||||
}
|
||||
|
||||
func humanMemoryInventory(history []interaction.HumanUtterance, assistantName string) string {
|
||||
rows := make([]string, 0, 4)
|
||||
if assistantName != "" {
|
||||
@@ -1826,14 +2078,6 @@ func latestAssistantName(history []interaction.HumanUtterance) string {
|
||||
return latestName
|
||||
}
|
||||
|
||||
func humanTimeDifferenceReply(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "моск") && strings.Contains(lower, "шанх") {
|
||||
return "Шанхай опережает Москву на 5 часов. Если в Москве 12:00, в Шанхае 17:00."
|
||||
}
|
||||
return "Уточните два города — я сравню их часовые пояса."
|
||||
}
|
||||
|
||||
func researchSourceLabel(finding epistemics.ResearchFinding) string {
|
||||
title := strings.TrimSpace(finding.Source.Title)
|
||||
locator := strings.TrimSpace(finding.Source.Locator)
|
||||
@@ -2003,6 +2247,26 @@ func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act int
|
||||
}
|
||||
}
|
||||
|
||||
// humanCurrentCognitiveModelReply answers about the route that produced this
|
||||
// reply. It intentionally does not infer a provider/model name from health:
|
||||
// that would claim a model was used when this direct status path did not call it.
|
||||
func humanCurrentCognitiveModelReply(health capability.Health, healthErr error) string {
|
||||
base := "Этот ответ сформирован встроенным режимом Домового; внешняя модель для него не использовалась."
|
||||
if healthErr != nil {
|
||||
return base + " Состояние канала мышления сейчас не удалось проверить."
|
||||
}
|
||||
switch health.State {
|
||||
case capability.HealthAvailable:
|
||||
return base + " Канал мышления доступен для следующих задач."
|
||||
case capability.HealthDegraded:
|
||||
return base + " Канал мышления работает с ограничениями."
|
||||
case capability.HealthUnavailable:
|
||||
return base + " Канал мышления сейчас недоступен."
|
||||
default:
|
||||
return base + " Состояние канала мышления пока не подтверждено."
|
||||
}
|
||||
}
|
||||
|
||||
func humanCognitiveDegradedReply(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "Сейчас не удалось обработать запрос."
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
@@ -34,6 +35,99 @@ func TestNearestFlightEvidenceDoesNotClaimMissingDepartureTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestFlightReplyFiltersUnrelatedSearchSnippets(t *testing.T) {
|
||||
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{
|
||||
{State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://naidy.example", Title: "Мебельная фурнитура"}, Excerpt: epistemics.SourceExcerpt{Text: "Комплектующие для мебели оптом и в розницу."}},
|
||||
{State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://rasp.example", Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание самолётов Москва — Казань, время вылета и прилёта."}},
|
||||
}}
|
||||
reply, backed := renderResearchReply("ближайшие авиарейсы Москва Казань сегодня", result, nil)
|
||||
if !backed || !strings.Contains(reply, "Рейсы Москва") || strings.Contains(reply, "Мебельная") {
|
||||
t.Fatalf("unrelated source leaked into flight reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestFlightEvidenceRequiresBothRouteEndpointsWhenKnown(t *testing.T) {
|
||||
query := "ближайшие авиарейсы Москва Казань сегодня"
|
||||
matched := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание авиарейсов из Москвы в Казань."}}
|
||||
if !researchFindingMatchesIntent(query, matched) {
|
||||
t.Fatal("route evidence with both endpoints was rejected")
|
||||
}
|
||||
unrelated := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Рейсы Москва — Казань", Locator: "https://example.test/moscow-kazan"}, Excerpt: epistemics.SourceExcerpt{Text: "Рейсы Москва — Казань — Ближайшие к Казани города; цена указана ориентировочно."}}
|
||||
if researchFindingMatchesIntent(query, unrelated) {
|
||||
t.Fatal("one-ended travel evidence was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketReplyRejectsGenericCategoryPageWithoutDestination(t *testing.T) {
|
||||
query := "найди билеты до Ялты сегодня"
|
||||
generic := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Купить билеты", Locator: "https://tickets.example"}, Excerpt: epistemics.SourceExcerpt{Text: "Поиск авиабилетов среди множества направлений."}}
|
||||
if researchFindingMatchesIntent(query, generic) {
|
||||
t.Fatal("generic ticket category was presented as destination result")
|
||||
}
|
||||
relevant := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Билеты"}, Excerpt: epistemics.SourceExcerpt{Text: "Билеты до Ялты: варианты на сегодня."}}
|
||||
if !researchFindingMatchesIntent(query, relevant) {
|
||||
t.Fatal("destination-specific ticket result was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchReplyRejectsGenericLandingPageForConcreteIdentifier(t *testing.T) {
|
||||
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
|
||||
State: epistemics.FindingSupported,
|
||||
Source: epistemics.Source{Locator: "https://market.example", Title: "Интернет-магазин"},
|
||||
Excerpt: epistemics.SourceExcerpt{Text: "Широкий ассортимент товаров и услуг."},
|
||||
}}}
|
||||
reply, backed := renderResearchReply("на WB бренд marka-wear", result, nil)
|
||||
if backed || !strings.Contains(reply, "marka-wear") || strings.Contains(reply, "wildberries") || !strings.Contains(reply, "уточните написание") {
|
||||
t.Fatalf("generic page was presented as a brand result: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchReplyKeepsFindingContainingConcreteIdentifier(t *testing.T) {
|
||||
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
|
||||
State: epistemics.FindingSupported,
|
||||
Source: epistemics.Source{Locator: "https://market.example/marka-wear", Title: "Marka-wear"},
|
||||
Excerpt: epistemics.SourceExcerpt{Text: "Бренд marka-wear: товары и карточки."},
|
||||
}}}
|
||||
reply, backed := renderResearchReply("на WB бренд marka-wear", result, nil)
|
||||
if !backed || !strings.Contains(strings.ToLower(reply), "marka-wear") {
|
||||
t.Fatalf("concrete identifier finding was rejected: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchIdentifiersExcludeSearchDate(t *testing.T) {
|
||||
identifiers := requestedResearchIdentifiers("ближайший поезд в Петербург сегодня 26.08.2026")
|
||||
if len(identifiers) != 0 {
|
||||
t.Fatalf("search date was treated as a concrete identifier: %#v", identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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, false, true, true, "Расписание без ссылки", "Расписание. Источник: https://example.test") {
|
||||
t.Fatal("review dropped the research evidence")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.Header.Set("Authorization", "Basic value")
|
||||
@@ -67,12 +161,35 @@ func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPriceEvidenceDistinguishesLinksFromPrices(t *testing.T) {
|
||||
if containsPriceEvidence("RTX 4090 — market.yandex.ru") {
|
||||
t.Fatal("a product link is not price evidence")
|
||||
func TestContainsScheduleTimeEvidence(t *testing.T) {
|
||||
if containsScheduleTimeEvidence("расписание поездов и цена билета") {
|
||||
t.Fatal("a price or schedule label is not a departure time")
|
||||
}
|
||||
if !containsPriceEvidence("Цена: 189 990 ₽") {
|
||||
t.Fatal("expected a currency amount to count as price evidence")
|
||||
if !containsScheduleTimeEvidence("Отправление в 07:45") {
|
||||
t.Fatal("expected an explicit departure time to be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternetStatusQueryRecognizesConnectionChecks(t *testing.T) {
|
||||
for _, query := range []string{"проверь, работает ли интернет", "есть доступ к сети?"} {
|
||||
if !isInternetStatusQuery(query) {
|
||||
t.Fatalf("connection check was not recognized: %q", query)
|
||||
}
|
||||
}
|
||||
if isInternetStatusQuery("найди интернет-магазин") {
|
||||
t.Fatal("ordinary search must not be treated as a connectivity check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternetStatusReplyDoesNotLeakIncidentalSearchResults(t *testing.T) {
|
||||
result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{
|
||||
State: epistemics.FindingSupported,
|
||||
Source: epistemics.Source{Locator: "https://example.test", Title: "Incidental page"},
|
||||
Excerpt: epistemics.SourceExcerpt{Text: "A response was received."},
|
||||
}}}
|
||||
reply, backed := renderResearchReply("проверь, работает ли интернет", result, nil)
|
||||
if !backed || reply != "Интернет работает: Домовой получил ответ от внешнего источника." {
|
||||
t.Fatalf("unexpected internet status reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +200,13 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentModelReplyDoesNotInventProvider(t *testing.T) {
|
||||
reply := humanCurrentCognitiveModelReply(capability.Health{State: capability.HealthDegraded, CapabilityID: "provider-secret-model"}, nil)
|
||||
if !strings.Contains(reply, "встроенным режимом") || !strings.Contains(reply, "ограничениями") || strings.Contains(reply, "provider-secret-model") {
|
||||
t.Fatalf("unexpected current-model reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
|
||||
if reply != "Канал мышления недоступен. Домовой остаётся на связи." {
|
||||
|
||||
@@ -251,6 +251,38 @@ func TestChatMessageUsesLocalAdvisoryForOrdinaryConversationalQuestion(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePreservesCompletedCoreAnswerInsteadOfSecondAdvisory(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, FallbackText: "fallback", PlanningReply: "Готовый ответ ядра."})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("completed core answer must not be replaced by a second advisory")
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Обычный вопрос"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
var response browserChatResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.ReplyText != "Готовый ответ ядра." {
|
||||
t.Fatalf("unexpected browser response: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
|
||||
@@ -28,7 +28,11 @@ import (
|
||||
const (
|
||||
maxRequestBytes = 8 << 10
|
||||
maxAdvisoryRequestBytes = 20 << 10
|
||||
maxResponseBytes = 1 << 20
|
||||
// Chat responses may contain a locally generated image encoded as base64.
|
||||
// Keep the gateway limit aligned with the bounded media transport (32 MiB),
|
||||
// otherwise a successfully generated image is incorrectly surfaced as an
|
||||
// unavailable runtime to the human.
|
||||
maxResponseBytes = 32 << 20
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -476,7 +480,10 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
saved.NeedsCognition = false
|
||||
}
|
||||
}
|
||||
if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
||||
// Core owns the completed planning answer. The browser may ask for a local
|
||||
// advisory only when core explicitly left the turn unanswered; otherwise a
|
||||
// second model pass can replace a grounded result with a weaker one.
|
||||
if saved.NeedsCognition && saved.PlanningReply == "" && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
||||
cognition = "unavailable"
|
||||
requestID, idErr := ids.New()
|
||||
if idErr == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,6 +14,25 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestMaxResponseBytesAcceptsGeneratedImagePayload(t *testing.T) {
|
||||
// A 1024px PNG can exceed the former 1 MiB gateway limit once base64
|
||||
// encoded. The browser gateway must forward that successful core response.
|
||||
payload, err := json.Marshal(interaction.ChatMessageResponse{
|
||||
GeneratedImageContentType: "image/png",
|
||||
GeneratedImageBase64: strings.Repeat("A", 2<<20),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded interaction.ChatMessageResponse
|
||||
if err := decodeStrictJSON(bytes.NewReader(payload), maxResponseBytes, &decoded); err != nil {
|
||||
t.Fatalf("generated image response must fit gateway limit: %v", err)
|
||||
}
|
||||
if len(decoded.GeneratedImageBase64) != 2<<20 {
|
||||
t.Fatalf("generated image payload was truncated: %d", len(decoded.GeneratedImageBase64))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) {
|
||||
context := localChatContext(interaction.ChatMessageResponse{AssistantName: "Фея", ActiveProjectName: "Домовой"})
|
||||
if len(context) != 2 || context[0].SourceID != "domovoy" || !strings.Contains(context[0].Text, "Ты — Фея") || !strings.Contains(context[0].Text, "Меня зовут Фея") || context[1].Text != "Текущий проект: Домовой" {
|
||||
|
||||
@@ -27,51 +27,54 @@ type SemanticCandidateSelectionState string
|
||||
type InformationRequestKind string
|
||||
|
||||
const (
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
ConversationOpen ConversationState = "OPEN"
|
||||
ConversationArchived ConversationState = "ARCHIVED"
|
||||
SpeechActDirective SpeechAct = "DIRECTIVE"
|
||||
SpeechActAssertion SpeechAct = "ASSERTION"
|
||||
SpeechActDecision SpeechAct = "DECISION"
|
||||
SpeechActPreference SpeechAct = "PREFERENCE"
|
||||
SpeechActQuestion SpeechAct = "QUESTION"
|
||||
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
|
||||
SpeechActCorrection SpeechAct = "CORRECTION"
|
||||
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
|
||||
SpeechActConfirmation SpeechAct = "CONFIRMATION"
|
||||
SpeechActRejection SpeechAct = "REJECTION"
|
||||
SpeechActCancellation SpeechAct = "CANCELLATION"
|
||||
SpeechActConversational SpeechAct = "CONVERSATIONAL"
|
||||
SpeechActUnknown SpeechAct = "UNKNOWN"
|
||||
InterpretationRecorded InterpretationState = "RECORDED"
|
||||
InterpretationInterpreted InterpretationState = "INTERPRETED"
|
||||
RelationCorrects InteractionRelationKind = "CORRECTS"
|
||||
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
|
||||
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
|
||||
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
|
||||
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
|
||||
RelationClarifies InteractionRelationKind = "CLARIFIES"
|
||||
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
|
||||
ProjectRoleRelated ProjectReferenceRole = "RELATED"
|
||||
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
|
||||
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
|
||||
ClarificationRequired ClarificationState = "REQUIRED"
|
||||
SemanticApplicable SemanticApplicability = "APPLICABLE"
|
||||
SemanticLowConfidence SemanticApplicability = "LOW_CONFIDENCE"
|
||||
SemanticMixedIntent SemanticApplicability = "MIXED_INTENT"
|
||||
SemanticNeedsBroaderInterpret SemanticApplicability = "NEEDS_BROADER_INTERPRETATION"
|
||||
SemanticCandidateClarify SemanticApplicability = "CLARIFICATION_REQUIRED"
|
||||
CandidateSourceDeterministic SemanticCandidateSource = "DETERMINISTIC_V2"
|
||||
CandidateSourceCognitive SemanticCandidateSource = "COGNITIVE_V1"
|
||||
CandidateSelected SemanticCandidateSelectionState = "SELECTED"
|
||||
CandidateObserved SemanticCandidateSelectionState = "OBSERVED"
|
||||
CandidateRejected SemanticCandidateSelectionState = "REJECTED"
|
||||
InformationRequestCapabilityInventory InformationRequestKind = "CAPABILITY_INVENTORY"
|
||||
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
ConversationOpen ConversationState = "OPEN"
|
||||
ConversationArchived ConversationState = "ARCHIVED"
|
||||
SpeechActDirective SpeechAct = "DIRECTIVE"
|
||||
SpeechActAssertion SpeechAct = "ASSERTION"
|
||||
SpeechActDecision SpeechAct = "DECISION"
|
||||
SpeechActPreference SpeechAct = "PREFERENCE"
|
||||
SpeechActQuestion SpeechAct = "QUESTION"
|
||||
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
|
||||
SpeechActCorrection SpeechAct = "CORRECTION"
|
||||
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
|
||||
SpeechActConfirmation SpeechAct = "CONFIRMATION"
|
||||
SpeechActRejection SpeechAct = "REJECTION"
|
||||
SpeechActCancellation SpeechAct = "CANCELLATION"
|
||||
SpeechActConversational SpeechAct = "CONVERSATIONAL"
|
||||
SpeechActUnknown SpeechAct = "UNKNOWN"
|
||||
InterpretationRecorded InterpretationState = "RECORDED"
|
||||
InterpretationInterpreted InterpretationState = "INTERPRETED"
|
||||
RelationCorrects InteractionRelationKind = "CORRECTS"
|
||||
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
|
||||
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
|
||||
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
|
||||
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
|
||||
RelationClarifies InteractionRelationKind = "CLARIFIES"
|
||||
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
|
||||
ProjectRoleRelated ProjectReferenceRole = "RELATED"
|
||||
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
|
||||
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
|
||||
ClarificationRequired ClarificationState = "REQUIRED"
|
||||
SemanticApplicable SemanticApplicability = "APPLICABLE"
|
||||
SemanticLowConfidence SemanticApplicability = "LOW_CONFIDENCE"
|
||||
SemanticMixedIntent SemanticApplicability = "MIXED_INTENT"
|
||||
SemanticNeedsBroaderInterpret SemanticApplicability = "NEEDS_BROADER_INTERPRETATION"
|
||||
SemanticCandidateClarify SemanticApplicability = "CLARIFICATION_REQUIRED"
|
||||
CandidateSourceDeterministic SemanticCandidateSource = "DETERMINISTIC_V2"
|
||||
CandidateSourceCognitive SemanticCandidateSource = "COGNITIVE_V1"
|
||||
CandidateSelected SemanticCandidateSelectionState = "SELECTED"
|
||||
CandidateObserved SemanticCandidateSelectionState = "OBSERVED"
|
||||
CandidateRejected SemanticCandidateSelectionState = "REJECTED"
|
||||
InformationRequestCapabilityInventory InformationRequestKind = "CAPABILITY_INVENTORY"
|
||||
InformationRequestLocalCapabilityInventory InformationRequestKind = "LOCAL_CAPABILITY_INVENTORY"
|
||||
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||
InformationRequestCognitiveCurrentModel InformationRequestKind = "COGNITIVE_CURRENT_MODEL"
|
||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||
InformationRequestResearchFailureExplanation InformationRequestKind = "RESEARCH_FAILURE_EXPLANATION"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
|
||||
@@ -128,6 +128,9 @@ func FallbackText(act interaction.SpeechAct) string {
|
||||
// of the same request.
|
||||
func PreferredAssistantName(message string) string {
|
||||
tokens := semanticTokens(message)
|
||||
if !hasAssistantNamingTarget(tokens) {
|
||||
return ""
|
||||
}
|
||||
for index, token := range tokens {
|
||||
if !containsStem(token, "называ", "назов", "зов", "имен") {
|
||||
continue
|
||||
@@ -170,6 +173,15 @@ func IsIdentityInquiry(message string) bool {
|
||||
return containsToken(tokens, "зовут") || containsToken(tokens, "имя")
|
||||
}
|
||||
|
||||
// IsExplicitMemoryInstruction recognizes a direct request to retain the
|
||||
// following human-provided context. It deliberately does not interpret the
|
||||
// content as a verified fact: the conversation owner keeps the original
|
||||
// utterance and its provenance.
|
||||
func IsExplicitMemoryInstruction(message string) bool {
|
||||
tokens := semanticTokens(message)
|
||||
return len(tokens) > 0 && containsStem(tokens[0], "запомни", "помни")
|
||||
}
|
||||
|
||||
func BuildDeterministicCandidate(message string, corrects interaction.HumanUtteranceID, millis int64) interaction.SemanticCandidate {
|
||||
id, _ := ids.New()
|
||||
tokens := semanticTokens(message)
|
||||
@@ -362,6 +374,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
}
|
||||
|
||||
func preferredNameFromTokens(tokens []string) bool {
|
||||
if !hasAssistantNamingTarget(tokens) {
|
||||
return false
|
||||
}
|
||||
for index, token := range tokens {
|
||||
if !containsStem(token, "называ", "назов", "зов", "имен") {
|
||||
continue
|
||||
@@ -376,6 +391,10 @@ func preferredNameFromTokens(tokens []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAssistantNamingTarget(tokens []string) bool {
|
||||
return containsToken(tokens, "тебя") || containsToken(tokens, "тебе") || containsToken(tokens, "ты")
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -593,7 +612,7 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
|
||||
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
||||
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
||||
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
|
||||
travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут")
|
||||
travelSearchCue := travelCue(tokens) || containsStemInTokens(tokens, "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут")
|
||||
timeDifferenceCue := containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час")
|
||||
return currentCue || observedStateCue || placeSearchCue || travelSearchCue || timeDifferenceCue
|
||||
}
|
||||
@@ -603,13 +622,33 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
|
||||
// source-dependent concepts such as weather.
|
||||
func approximateToken(tokens []string, target string, maxDistance int) bool {
|
||||
for _, token := range tokens {
|
||||
if editDistance([]rune(token), []rune(target)) <= maxDistance {
|
||||
if nearToken(token, target, maxDistance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nearToken(token, target string, maxDistance int) bool {
|
||||
if editDistance([]rune(token), []rune(target)) <= maxDistance {
|
||||
return true
|
||||
}
|
||||
if maxDistance < 1 {
|
||||
return false
|
||||
}
|
||||
left, right := []rune(token), []rune(target)
|
||||
if len(left) != len(right) || len(left) < 2 {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] == right[index] {
|
||||
continue
|
||||
}
|
||||
return index+1 < len(left) && left[index] == right[index+1] && left[index+1] == right[index] && string(left[index+2:]) == string(right[index+2:])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func editDistance(left, right []rune) int {
|
||||
previous := make([]int, len(right)+1)
|
||||
for index := range previous {
|
||||
@@ -651,15 +690,24 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
if containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") {
|
||||
return interaction.InformationRequestTimeDifference
|
||||
}
|
||||
if containsStemInTokens(tokens, "почему", "зачем") && containsStemInTokens(tokens, "смог", "неполуч", "нашел", "нашёл") && containsStemInTokens(tokens, "ответ", "данн", "найт") {
|
||||
return interaction.InformationRequestResearchFailureExplanation
|
||||
}
|
||||
inquiry := candidate.PrimarySpeechAct == interaction.SpeechActQuestion || candidate.PrimarySpeechAct == interaction.SpeechActRequestToInvestigate || candidate.SecondarySpeechAct == interaction.SpeechActQuestion || candidate.SecondarySpeechAct == interaction.SpeechActRequestToInvestigate
|
||||
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
|
||||
if cognitiveSubject && inquiry && containsStemInTokens(tokens, "какая", "какой", "текущ", "сейчас", "отвеч") && containsStemInTokens(tokens, "модел") {
|
||||
return interaction.InformationRequestCognitiveCurrentModel
|
||||
}
|
||||
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
|
||||
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
|
||||
inventoryRequest := inquiry || containsStemInTokens(tokens, "назов", "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
|
||||
if cognitiveSubject && inventoryRequest && inventoryCue {
|
||||
return interaction.InformationRequestCognitiveModelInventory
|
||||
}
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "capabilit")
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
||||
if inquiry && capabilitySubject {
|
||||
if containsStemInTokens(tokens, "без", "внешн", "локальн") && containsStemInTokens(tokens, "модел", "ии", "ai") {
|
||||
return interaction.InformationRequestLocalCapabilityInventory
|
||||
}
|
||||
return interaction.InformationRequestCapabilityInventory
|
||||
}
|
||||
recoveryCue := containsStemInTokens(tokens, "восстанов", "исправ", "почин", "подключ", "настро", "недоступ", "нет")
|
||||
@@ -673,14 +721,8 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
}
|
||||
|
||||
func LocationContextReply(message string) string {
|
||||
tokens := semanticTokens(message)
|
||||
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
|
||||
return ""
|
||||
}
|
||||
for index, token := range tokens {
|
||||
if (token == "в" || token == "на") && index+1 < len(tokens) {
|
||||
return "Запомнил: сейчас вы в " + capitalizeWord(tokens[index+1]) + "."
|
||||
}
|
||||
if location := personalCurrentLocation(message); location != "" {
|
||||
return "Запомнил: сейчас вы в " + location + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -693,6 +735,17 @@ func IsCreativeRequest(message string) bool {
|
||||
return isCreativeRequestTokens(tokens)
|
||||
}
|
||||
|
||||
// TimeDifferenceResearchQuery makes the requested measurement explicit for a
|
||||
// current-data capability. It preserves the user-provided entities and does
|
||||
// not encode any particular place or time-zone relationship.
|
||||
func TimeDifferenceResearchQuery(message string) string {
|
||||
tokens := semanticTokens(StripAssistantVocative(message))
|
||||
if containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") && containsToken(tokens, "между") {
|
||||
return strings.TrimSpace(StripAssistantVocative(message)) + " сколько часов"
|
||||
}
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
|
||||
// CreativeDeterministicReply intentionally has no example-specific answer
|
||||
// branches. Creative requests must retain their full meaning and go through
|
||||
// cognition; canned object or color answers hide lost attributes.
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestNewHumanUtteranceClassifiesReminderAndCapabilityConnectionAsDirective(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaneRequestReceivesTravelResearchCapability(t *testing.T) {
|
||||
for _, message := range []string{"найди ближайший самолет до Казани", "найди сомалет до Казани"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", 100)
|
||||
if !containsToken(candidate.CapabilityHints, "WEB_RESEARCH") {
|
||||
t.Fatalf("plane request did not select web research for %q: %+v", message, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicCandidateIsInvariantToMissingQuestionMarkForIdentityAndArithmetic(t *testing.T) {
|
||||
cases := []struct {
|
||||
message string
|
||||
@@ -118,6 +127,10 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
|
||||
kind interaction.InformationRequestKind
|
||||
}{
|
||||
{"какие модели доступны?", interaction.InformationRequestCognitiveModelInventory},
|
||||
{"назови доступные модели", interaction.InformationRequestCognitiveModelInventory},
|
||||
{"какая модель отвечает?", interaction.InformationRequestCognitiveCurrentModel},
|
||||
{"почему ты не смог найти ответ?", interaction.InformationRequestResearchFailureExplanation},
|
||||
{"что ты можешь сделать без внешних моделей?", interaction.InformationRequestLocalCapabilityInventory},
|
||||
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
||||
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
||||
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
||||
@@ -200,6 +213,20 @@ func TestDecomposeUsesRememberedAliasesAcrossWordings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLexiconFromHistoryFeedsSearchQueryWithoutCoreAlias(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "запомни: WB — это Wildberries, источник для поиска"}}
|
||||
lexicon := TaskLexiconFromHistory(history)
|
||||
if got := SearchQueryWithLexicon("найди на WB бренд marka-wear", lexicon); got != "на WB бренд marka-wear wildberries" {
|
||||
t.Fatalf("learned source was not applied to search: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchQueryWithoutLearnedSourceOmitsImperative(t *testing.T) {
|
||||
if got := SearchQueryWithLexicon("найди на WB бренд marka-wear", TaskLexicon{}); got != "на WB бренд marka-wear" {
|
||||
t.Fatalf("search imperative leaked into external query: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecomposeCreativeRequestDoesNotInventResearch(t *testing.T) {
|
||||
lexicon := TaskLexicon{ObjectAliases: []TaskLexiconEntry{{Alias: "медвед", Value: "IMAGE"}}}
|
||||
got := DecomposeTaskWithLexicon("нарисуй бурого медведя", lexicon)
|
||||
@@ -240,9 +267,21 @@ func TestNamingPreferenceIsSemanticAndProducesNaturalAcceptance(t *testing.T) {
|
||||
if name := PreferredAssistantName("давай переименуем тебя в фею. теперь тебя зовут фея"); name != "Фея" {
|
||||
t.Fatalf("rename phrase returned %q", name)
|
||||
}
|
||||
if name := PreferredAssistantName("назови доступные модели"); name != "" {
|
||||
t.Fatalf("model inventory request was mistaken for a name: %q", name)
|
||||
}
|
||||
if result := BuildDeterministicCandidate("назови доступные модели", "", time.Unix(10, 0).UnixMilli()); result.PrimarySpeechAct == interaction.SpeechActPreference {
|
||||
t.Fatalf("model inventory request was mistaken for a preference: %+v", result)
|
||||
}
|
||||
if !IsIdentityInquiry("ты кто?") || !IsIdentityInquiry("как тебя зовут") {
|
||||
t.Fatal("identity variants should be recognized")
|
||||
}
|
||||
if !IsExplicitMemoryInstruction("запомни: сокращение — это источник для поиска") {
|
||||
t.Fatal("explicit memory instruction was not recognized")
|
||||
}
|
||||
if IsExplicitMemoryInstruction("что ты помнишь обо мне?") {
|
||||
t.Fatal("memory inventory question must not be treated as an instruction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryInventoryRequestIsSemantic(t *testing.T) {
|
||||
@@ -325,6 +364,13 @@ func TestTimeDifferenceRequestIsSemantic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeDifferenceResearchQueryKeepsEntitiesAndAsksForMeasurement(t *testing.T) {
|
||||
got := TimeDifferenceResearchQuery("какая разница во времени между Москвой и Шанхаем?")
|
||||
if got != "какая разница во времени между Москвой и Шанхаем? сколько часов" {
|
||||
t.Fatalf("time difference query = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicCandidateKeepsFirstPersonCompletedWorkAsAssertion(t *testing.T) {
|
||||
candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli())
|
||||
if candidate.PrimarySpeechAct != interaction.SpeechActAssertion {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
// PersonalMemory is a rebuildable context projection. Its source of truth is
|
||||
// the append-only human utterance history; it is deliberately not a second
|
||||
// canonical store. Each fact retains the source utterance and confidence so a
|
||||
// later cognitive or capability route can distinguish a human statement from
|
||||
// an externally verified fact.
|
||||
type PersonalMemory struct {
|
||||
Facts []PersonalMemoryFact
|
||||
}
|
||||
|
||||
type PersonalMemoryFact struct {
|
||||
Kind string
|
||||
Value string
|
||||
ConfidenceMillis int64
|
||||
SourceUtteranceID interaction.HumanUtteranceID
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
|
||||
|
||||
// CompilePersonalMemory turns direct, first-person human statements into a
|
||||
// small typed context. It deliberately preserves preferences as statements
|
||||
// rather than pretending that an arbitrary preference is a verified fact.
|
||||
func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory {
|
||||
latestLocation := PersonalMemoryFact{}
|
||||
preferences := make([]PersonalMemoryFact, 0, 4)
|
||||
for _, utterance := range history {
|
||||
if location := personalCurrentLocation(utterance.Content); location != "" {
|
||||
fact := PersonalMemoryFact{
|
||||
Kind: personalMemoryCurrentLocation,
|
||||
Value: location,
|
||||
ConfidenceMillis: 950,
|
||||
SourceUtteranceID: utterance.ID,
|
||||
ObservedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
}
|
||||
if fact.ObservedAtUnixMillis >= latestLocation.ObservedAtUnixMillis {
|
||||
latestLocation = fact
|
||||
}
|
||||
}
|
||||
if preference := personalPreference(utterance.Content); preference != "" {
|
||||
preferences = append(preferences, PersonalMemoryFact{
|
||||
Kind: "PREFERENCE",
|
||||
Value: preference,
|
||||
ConfidenceMillis: 900,
|
||||
SourceUtteranceID: utterance.ID,
|
||||
ObservedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
})
|
||||
}
|
||||
}
|
||||
result := PersonalMemory{Facts: make([]PersonalMemoryFact, 0, 1+len(preferences))}
|
||||
if latestLocation.Kind != "" {
|
||||
result.Facts = append(result.Facts, latestLocation)
|
||||
}
|
||||
sort.SliceStable(preferences, func(left, right int) bool {
|
||||
return preferences[left].ObservedAtUnixMillis > preferences[right].ObservedAtUnixMillis
|
||||
})
|
||||
if len(preferences) > 8 {
|
||||
preferences = preferences[:8]
|
||||
}
|
||||
result.Facts = append(result.Facts, preferences...)
|
||||
return result
|
||||
}
|
||||
|
||||
func (m PersonalMemory) CurrentLocation() string {
|
||||
for _, fact := range m.Facts {
|
||||
if fact.Kind == personalMemoryCurrentLocation {
|
||||
return fact.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContextText is compact, source-aware context for a cognitive or skill
|
||||
// request. It contains facts rather than a copied chat transcript.
|
||||
func (m PersonalMemory) ContextText() string {
|
||||
lines := make([]string, 0, len(m.Facts))
|
||||
for _, fact := range m.Facts {
|
||||
value := strings.TrimSpace(fact.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
switch fact.Kind {
|
||||
case personalMemoryCurrentLocation:
|
||||
lines = append(lines, "Текущее место пользователя (его сообщение): "+value)
|
||||
case "PREFERENCE":
|
||||
lines = append(lines, "Предпочтение пользователя (его сообщение): "+value)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func personalCurrentLocation(message string) string {
|
||||
tokens := semanticTokens(message)
|
||||
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
|
||||
return ""
|
||||
}
|
||||
for index, token := range tokens {
|
||||
if (token == "в" || token == "на") && index+1 < len(tokens) {
|
||||
return capitalizeWord(tokens[index+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func personalPreference(message string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
for _, cue := range []string{"мне нравится", "я люблю"} {
|
||||
index := strings.Index(lower, cue)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(lower[index+len(cue):])
|
||||
value = strings.Trim(value, " .,!?:;—-")
|
||||
if len([]rune(value)) >= 2 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestCompilePersonalMemoryKeepsLatestLocationAndPreferences(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{
|
||||
{ID: "location-old", Content: "я сейчас в Санкт-Петербурге", ReceivedAtUnixMillis: 100},
|
||||
{ID: "color", Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 200},
|
||||
{ID: "location-new", Content: "я сейчас в Москве", ReceivedAtUnixMillis: 300},
|
||||
})
|
||||
if got := memory.CurrentLocation(); got != "Москве" {
|
||||
t.Fatalf("current location = %q", got)
|
||||
}
|
||||
context := memory.ContextText()
|
||||
if !strings.Contains(context, "Москве") || !strings.Contains(context, "красный цвет") || strings.Contains(context, "Санкт-Петербурге") {
|
||||
t.Fatalf("unexpected compact memory context: %q", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryDoesNotTreatThirdPartyStatementAsPersonalFact(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{{Content: "Погода сейчас в Москве", ReceivedAtUnixMillis: 100}})
|
||||
if memory.CurrentLocation() != "" || memory.ContextText() != "" {
|
||||
t.Fatalf("ordinary factual question became personal memory: %+v", memory)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,15 @@ func TestLocalSearchClarification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketSearchDestinationUsesRequestDestination(t *testing.T) {
|
||||
if got := TicketSearchDestination("найди билеты до Ялты сегодня"); got != "Ялты" {
|
||||
t.Fatalf("ticket destination = %q", got)
|
||||
}
|
||||
if got := TicketSearchDestination("найди билеты"); got != "" {
|
||||
t.Fatalf("destination invented without one: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueLocalSearch(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "Найди ближайший ресторан"}}
|
||||
if got := ContinueLocalSearch("в Сочи", history); got != "Найди ближайший ресторан в Сочи" {
|
||||
@@ -108,6 +117,19 @@ func TestContinueTravelOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueTravelDate(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "Найди билеты до Ялты", ReceivedAtUnixMillis: 100}}
|
||||
if got := ContinueTravelDate("сегодня", history); got != "Найди билеты до Ялты сегодня" {
|
||||
t.Fatalf("date answer did not complete ticket search: %q", got)
|
||||
}
|
||||
if got := ContinueTravelDate("завтра", []interaction.HumanUtterance{{Content: "Найди билеты до Ялты сегодня", ReceivedAtUnixMillis: 100}}); got != "" {
|
||||
t.Fatalf("dated ticket search must not be continued again: %q", got)
|
||||
}
|
||||
if got := ContinueTravelDate("сегодня", []interaction.HumanUtterance{{Content: "Какая погода в Ялте?", ReceivedAtUnixMillis: 100}}); got != "" {
|
||||
t.Fatalf("date answer must not create travel work without travel context: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelSearchClarification(t *testing.T) {
|
||||
if got := TravelSearchClarification("Найди билеты", nil); got == "" {
|
||||
t.Fatal("underspecified travel request must ask for missing route and date")
|
||||
@@ -159,6 +181,78 @@ func TestTravelSearchQueryCarriesNearestFlightDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelResearchQueryUsesKnownOriginForDistance(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
|
||||
got := TravelResearchQuery("сколько километров до Сочи?", history, time.Time{})
|
||||
if got != "расстояние между Москве и Сочи в километрах по прямой и по дороге" {
|
||||
t.Fatalf("distance query lost route context: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelRouteWithKnownOriginDoesNotAskForTicketDate(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
|
||||
if got := TravelSearchClarification("как можно добраться до Сочи?", history); got != "" {
|
||||
t.Fatalf("general route was incorrectly reduced to ticket clarification: %q", got)
|
||||
}
|
||||
got := TravelResearchQuery("как можно добраться до Сочи?", history, time.Time{})
|
||||
if got != "способы добраться из Москве в Сочи: самолет поезд автобус автомобиль" {
|
||||
t.Fatalf("general route query lost known route context: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelContextUsesNewestLocationRegardlessOfRepositoryOrder(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{Content: "я сейчас в Санкт-Петербурге", ReceivedAtUnixMillis: 200},
|
||||
{Content: "я сейчас в Москве", ReceivedAtUnixMillis: 300},
|
||||
{Content: "я сейчас в Казани", ReceivedAtUnixMillis: 100},
|
||||
}
|
||||
if got := TravelSearchClarification("какой ближайший рейс до Сочи?", history); got != "" {
|
||||
t.Fatalf("newest remembered origin was not applied: %q", got)
|
||||
}
|
||||
if got := TravelResearchQuery("какой ближайший рейс до Сочи?", history, time.Time{}); !strings.Contains(got, "пункт отправления: Москве") {
|
||||
t.Fatalf("newest origin missing from travel research query: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestPlaneUsesTravelContextAndCurrentDay(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 25, 18, 0, 0, 0, time.UTC)
|
||||
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве", ReceivedAtUnixMillis: 100}}
|
||||
query := TravelResearchQuery("найди ближайший самолет до Казани", history, now)
|
||||
if query != "ближайшие авиарейсы Москве Казани сегодня 25.08.2026" {
|
||||
t.Fatalf("nearest plane search formulation lost current context: %q", query)
|
||||
}
|
||||
if got := TravelSearchClarification("найди ближайший самолет до Казани", history); got != "" {
|
||||
t.Fatalf("known origin and nearest-time meaning should proceed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelResearchQueryUsesKnownOriginForTrainSchedule(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 26, 9, 0, 0, 0, time.UTC)
|
||||
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
|
||||
got := TravelResearchQuery("найди поезд до Петербурга", history, now)
|
||||
want := "расписание поездов из Москве в Петербурга время отправления сегодня 26.08.2026"
|
||||
if got != want {
|
||||
t.Fatalf("train schedule query lost route context or primary outcome: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTravelResearchQueryNormalizesNearUnambiguousTravelTypo(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}}
|
||||
got := TravelResearchQuery("найди сомалет до Казани", history, time.Time{})
|
||||
if !strings.Contains(got, "самолёт") || !strings.Contains(got, "пункт отправления: Москве") {
|
||||
t.Fatalf("travel typo was not normalized with remembered route context: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndependentRequestPartsKeepsCoordinatedEntitiesTogether(t *testing.T) {
|
||||
if got := IndependentRequestParts("какая разница во времени между Москвой и Шанхаем?"); len(got) != 1 {
|
||||
t.Fatalf("coordinated entities became independent requests: %#v", got)
|
||||
}
|
||||
if got := IndependentRequestParts("проверь погоду в Казани и найди кафе рядом"); len(got) != 2 {
|
||||
t.Fatalf("independent actions were not decomposed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) {
|
||||
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
|
||||
|
||||
@@ -2,6 +2,8 @@ package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/interaction"
|
||||
|
||||
// TaskDecomposition is the small, typed intermediate representation used
|
||||
// before a capability is called. It separates the user's goal from the
|
||||
// object, source and target instead of treating the whole sentence as a
|
||||
@@ -38,6 +40,70 @@ func DecomposeTask(message string) TaskDecomposition {
|
||||
return DecomposeTaskWithLexicon(message, TaskLexicon{})
|
||||
}
|
||||
|
||||
// TaskLexiconFromHistory rebuilds learned search-source aliases from ordinary
|
||||
// human utterances. The fact remains user-owned conversational memory; core
|
||||
// code never embeds mutable marketplace or provider names.
|
||||
//
|
||||
// A source fact has the deliberately explicit form:
|
||||
// "запомни: <alias> — это <source>, источник для поиска".
|
||||
func TaskLexiconFromHistory(history []interaction.HumanUtterance) TaskLexicon {
|
||||
lexicon := TaskLexicon{}
|
||||
for _, utterance := range history {
|
||||
message := strings.TrimSpace(utterance.Content)
|
||||
lower := strings.ToLower(message)
|
||||
if !strings.Contains(lower, "источник") || !strings.Contains(lower, "поиск") || !strings.Contains(lower, "это") {
|
||||
continue
|
||||
}
|
||||
before, after, found := strings.Cut(lower, "это")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
alias := strings.TrimSpace(strings.TrimPrefix(before, "запомни:"))
|
||||
alias = strings.Trim(alias, " \t—-:;,")
|
||||
value := strings.TrimSpace(after)
|
||||
if index := strings.IndexAny(value, ",;."); index >= 0 {
|
||||
value = strings.TrimSpace(value[:index])
|
||||
}
|
||||
if len([]rune(alias)) < 2 || len([]rune(value)) < 2 {
|
||||
continue
|
||||
}
|
||||
lexicon.SourceAliases = append(lexicon.SourceAliases, TaskLexiconEntry{Alias: alias, Value: value})
|
||||
}
|
||||
return lexicon
|
||||
}
|
||||
|
||||
// SearchQueryWithLexicon prepares a search-engine query without turning a
|
||||
// learned source alias into core vocabulary. The imperative that expresses the
|
||||
// user's intent is deliberately omitted: search engines must receive the
|
||||
// subject of the request, not mistake a Russian command such as "найди" for a
|
||||
// brand or site name.
|
||||
func SearchQueryWithLexicon(message string, lexicon TaskLexicon) string {
|
||||
decomposition := DecomposeTaskWithLexicon(message, lexicon)
|
||||
if decomposition.Intent == TaskIntentSearch && decomposition.Source != "" {
|
||||
if decomposition.Target != "" {
|
||||
return decomposition.Target + " " + decomposition.Source
|
||||
}
|
||||
return searchQueryWithoutImperative(message) + " " + decomposition.Source
|
||||
}
|
||||
if decomposition.Intent == TaskIntentSearch {
|
||||
return searchQueryWithoutImperative(message)
|
||||
}
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
|
||||
func searchQueryWithoutImperative(message string) string {
|
||||
cleaned := strings.TrimSpace(message)
|
||||
fields := strings.Fields(cleaned)
|
||||
if len(fields) < 2 {
|
||||
return cleaned
|
||||
}
|
||||
first := strings.Trim(strings.ToLower(fields[0]), ".,!?:;")
|
||||
if !containsStem(first, "найд", "поищ", "подбер", "проверь") {
|
||||
return cleaned
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(cleaned, fields[0]))
|
||||
}
|
||||
|
||||
// DecomposeTaskWithLexicon performs a cheap semantic pass. The lexicon is
|
||||
// intentionally an input: a remembered alias may be revalidated or replaced
|
||||
// without changing the parser or shipping a new hard-coded branch.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// TravelSearchQuery carries the implicit current-day meaning of a nearest
|
||||
// flight request into the research adapter.
|
||||
func TravelSearchQuery(message string, now time.Time) string {
|
||||
cleaned := strings.TrimSpace(StripAssistantVocative(message))
|
||||
cleaned := normalizedTravelSearchText(message)
|
||||
tokens := semanticTokens(cleaned)
|
||||
if !travelCue(tokens) || !containsStemInTokens(tokens, "ближ") || isTravelDateSpecified(tokens) {
|
||||
return cleaned
|
||||
@@ -21,6 +21,98 @@ func TravelSearchQuery(message string, now time.Time) string {
|
||||
return cleaned + " сегодня " + now.Format("02.01.2006")
|
||||
}
|
||||
|
||||
// normalizedTravelSearchText corrects only a near-unambiguous typo in a
|
||||
// capability word before an external query. The original utterance is kept by
|
||||
// Conversation; this is a replaceable search-skill normalization, not a fact
|
||||
// about a user, city or provider.
|
||||
func normalizedTravelSearchText(message string) string {
|
||||
tokens := semanticTokens(StripAssistantVocative(message))
|
||||
changed := false
|
||||
for index, token := range tokens {
|
||||
if nearToken(token, "самолет", 2) || nearToken(token, "самолёт", 2) {
|
||||
tokens[index] = "самолёт"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return strings.TrimSpace(StripAssistantVocative(message))
|
||||
}
|
||||
return strings.Join(tokens, " ")
|
||||
}
|
||||
|
||||
// TravelResearchQuery keeps route and distance questions tied to a known
|
||||
// origin before they reach a generic web search. The history is semantic
|
||||
// context, not a list of example-specific answers.
|
||||
func TravelResearchQuery(message string, history []interaction.HumanUtterance, now time.Time) string {
|
||||
query := TravelSearchQuery(message, now)
|
||||
tokens := semanticTokens(StripAssistantVocative(message))
|
||||
if containsStemInTokens(tokens, "расстоян", "километр") {
|
||||
origin := travelContextOrigin(history)
|
||||
destination := travelContextDestinationFromTokens(tokens)
|
||||
if origin != "" && destination != "" {
|
||||
return "расстояние между " + origin + " и " + destination + " в километрах по прямой и по дороге"
|
||||
}
|
||||
if origin != "" && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
||||
query += " точка отправления: " + origin
|
||||
}
|
||||
query += " расстояние по прямой и по дороге"
|
||||
} else if containsStemInTokens(tokens, "добрат", "маршрут") {
|
||||
origin := travelContextOrigin(history)
|
||||
destination := travelContextDestinationFromTokens(tokens)
|
||||
if origin != "" && destination != "" {
|
||||
return "способы добраться из " + origin + " в " + destination + ": самолет поезд автобус автомобиль"
|
||||
}
|
||||
if origin != "" && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
||||
query += " отправление из " + origin
|
||||
}
|
||||
} else if trainScheduleRequest(tokens) {
|
||||
origin := travelContextOrigin(history)
|
||||
destination := travelContextDestinationFromTokens(tokens)
|
||||
if origin != "" && destination != "" {
|
||||
return trainScheduleResearchQuery(origin, destination, now, containsStemInTokens(tokens, "ближ"))
|
||||
}
|
||||
} else if travelCue(tokens) && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
||||
origin := travelContextOrigin(history)
|
||||
destination := travelContextDestinationFromTokens(tokens)
|
||||
if nearestAirTravelRequest(tokens) && origin != "" && destination != "" {
|
||||
return nearestAirTravelSearchQuery(origin, destination, now)
|
||||
}
|
||||
if origin != "" {
|
||||
// Preserve the remembered location as an explicit search parameter
|
||||
// instead of guessing its grammatical form in the user's language.
|
||||
query += " пункт отправления: " + origin
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// 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.
|
||||
func trainScheduleResearchQuery(origin, destination string, now time.Time, nearest bool) string {
|
||||
prefix := "расписание поездов"
|
||||
if nearest {
|
||||
prefix = "ближайшие поезда"
|
||||
}
|
||||
query := prefix + " из " + origin + " в " + destination + " время отправления"
|
||||
if !now.IsZero() {
|
||||
query += " сегодня " + now.Format("02.01.2006")
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// nearestAirTravelSearchQuery is a capability-local search formulation, not a
|
||||
// provider or place lookup. It removes conversational imperatives while
|
||||
// preserving the two user-derived route endpoints and the implicit current-day
|
||||
// meaning of "ближайший".
|
||||
func nearestAirTravelSearchQuery(origin, destination string, now time.Time) string {
|
||||
query := "ближайшие авиарейсы " + origin + " " + destination + " сегодня"
|
||||
if !now.IsZero() {
|
||||
query += " " + now.Format("02.01.2006")
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// StripAssistantVocative removes a leading assistant name used as an address.
|
||||
// The original utterance remains the historical source; this value is only for
|
||||
// interpretation, cognitive prompts and external queries.
|
||||
@@ -45,6 +137,9 @@ func StripAssistantVocative(message string) string {
|
||||
// conjunction; the cognitive route remains responsible for ambiguous prose.
|
||||
func IndependentRequestParts(message string) []string {
|
||||
cleaned := StripAssistantVocative(message)
|
||||
if containsToken(semanticTokens(cleaned), "между") {
|
||||
return []string{cleaned}
|
||||
}
|
||||
parts := strings.Split(cleaned, " и ")
|
||||
if len(parts) < 2 {
|
||||
return []string{cleaned}
|
||||
@@ -153,6 +248,44 @@ func ContinueTravelOrigin(message string, history []interaction.HumanUtterance)
|
||||
return strings.TrimSpace(previous + preposition + location)
|
||||
}
|
||||
|
||||
// ContinueTravelDate completes the most recent unfinished ticket search with
|
||||
// a short date answer. The date is still the human's original wording, so the
|
||||
// later route and research stages retain its provenance and can interpret it
|
||||
// relative to the current time.
|
||||
func ContinueTravelDate(message string, history []interaction.HumanUtterance) string {
|
||||
current := strings.TrimSpace(StripAssistantVocative(message))
|
||||
currentTokens := semanticTokens(current)
|
||||
if len(currentTokens) == 0 || len(currentTokens) > 6 || !isTravelDateSpecified(currentTokens) {
|
||||
return ""
|
||||
}
|
||||
latestAt := int64(-1)
|
||||
latest := ""
|
||||
for _, utterance := range history {
|
||||
tokens := semanticTokens(utterance.Content)
|
||||
if !travelCue(tokens) || isTravelDateSpecified(tokens) || containsStemInTokens(tokens, "ближ") {
|
||||
continue
|
||||
}
|
||||
if utterance.ReceivedAtUnixMillis >= latestAt {
|
||||
latestAt = utterance.ReceivedAtUnixMillis
|
||||
latest = strings.TrimSpace(StripAssistantVocative(utterance.Content))
|
||||
}
|
||||
}
|
||||
if latest == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(latest + " " + current)
|
||||
}
|
||||
|
||||
// TicketSearchDestination extracts the destination from a ticket request
|
||||
// without assigning any city or travel-provider meaning in core code.
|
||||
func TicketSearchDestination(message string) string {
|
||||
tokens := semanticTokens(StripAssistantVocative(message))
|
||||
if !containsStemInTokens(tokens, "билет") {
|
||||
return ""
|
||||
}
|
||||
return travelContextDestinationFromTokens(tokens)
|
||||
}
|
||||
|
||||
// TravelSearchClarification keeps an incomplete travel request in dialogue
|
||||
// instead of reducing it to an unrelated generic web search.
|
||||
func TravelSearchClarification(message string, history []interaction.HumanUtterance) string {
|
||||
@@ -186,13 +319,7 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera
|
||||
}
|
||||
}
|
||||
}
|
||||
if origin && destination && date {
|
||||
return ""
|
||||
}
|
||||
ticketRequest := containsStemInTokens(current, "билет", "рейс", "самолет", "самолёт", "вылет")
|
||||
if !ticketRequest && origin && destination {
|
||||
return ""
|
||||
}
|
||||
if !destination {
|
||||
if contextDestination := travelContextDestination(history); contextDestination != "" {
|
||||
destination = true
|
||||
@@ -209,6 +336,9 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera
|
||||
origin = true
|
||||
}
|
||||
}
|
||||
if origin && destination && (date || !ticketRequest) {
|
||||
return ""
|
||||
}
|
||||
if destination && !origin && !date {
|
||||
if !ticketRequest {
|
||||
return "Из какого города выезжать в " + destinationLabel(current, history) + "? Подберу самолёт, поезд, автобус или машину."
|
||||
@@ -219,6 +349,9 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera
|
||||
return "Из какого города вылетать?"
|
||||
}
|
||||
if destination && !date {
|
||||
if !ticketRequest {
|
||||
return ""
|
||||
}
|
||||
return "На какую дату нужны билеты?"
|
||||
}
|
||||
return "Уточните маршрут и дату: откуда, куда и на какой день нужны билеты?"
|
||||
@@ -252,9 +385,23 @@ func destinationLabel(current []string, history []interaction.HumanUtterance) st
|
||||
return "пункт назначения"
|
||||
}
|
||||
|
||||
func travelContextDestinationFromTokens(tokens []string) string {
|
||||
for index, token := range tokens {
|
||||
if token != "до" && token != "в" && token != "к" {
|
||||
continue
|
||||
}
|
||||
if index+1 < len(tokens) && len([]rune(tokens[index+1])) >= 3 {
|
||||
return capitalizeWord(tokens[index+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func travelContextDestination(history []interaction.HumanUtterance) string {
|
||||
for index := len(history) - 1; index >= 0; index-- {
|
||||
tokens := semanticTokens(history[index].Content)
|
||||
latestAt := int64(-1)
|
||||
latest := ""
|
||||
for _, utterance := range history {
|
||||
tokens := semanticTokens(utterance.Content)
|
||||
if !travelContextCue(tokens) {
|
||||
continue
|
||||
}
|
||||
@@ -263,26 +410,19 @@ func travelContextDestination(history []interaction.HumanUtterance) string {
|
||||
continue
|
||||
}
|
||||
if tokenIndex+1 < len(tokens) && len([]rune(tokens[tokenIndex+1])) >= 3 {
|
||||
return capitalizeWord(tokens[tokenIndex+1])
|
||||
if utterance.ReceivedAtUnixMillis >= latestAt {
|
||||
latestAt = utterance.ReceivedAtUnixMillis
|
||||
latest = capitalizeWord(tokens[tokenIndex+1])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return latest
|
||||
}
|
||||
|
||||
func travelContextOrigin(history []interaction.HumanUtterance) string {
|
||||
for index := len(history) - 1; index >= 0; index-- {
|
||||
tokens := semanticTokens(history[index].Content)
|
||||
if len(tokens) < 3 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
|
||||
continue
|
||||
}
|
||||
for tokenIndex, token := range tokens {
|
||||
if (token == "в" || token == "из") && tokenIndex+1 < len(tokens) {
|
||||
return capitalizeWord(tokens[tokenIndex+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return CompilePersonalMemory(history).CurrentLocation()
|
||||
}
|
||||
|
||||
func travelContextCue(tokens []string) bool {
|
||||
@@ -298,7 +438,16 @@ func capitalizeWord(value string) string {
|
||||
}
|
||||
|
||||
func travelCue(tokens []string) bool {
|
||||
return containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт")
|
||||
return containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт") || approximateToken(tokens, "самолет", 2) || approximateToken(tokens, "самолёт", 2)
|
||||
}
|
||||
|
||||
func trainScheduleRequest(tokens []string) bool {
|
||||
return containsStemInTokens(tokens, "поезд") &&
|
||||
(containsStemInTokens(tokens, "найд", "ближ", "расписан", "отправ", "прибуд", "едет", "едут") || len(tokens) > 1)
|
||||
}
|
||||
|
||||
func nearestAirTravelRequest(tokens []string) bool {
|
||||
return containsStemInTokens(tokens, "ближ") && containsStemInTokens(tokens, "самолет", "самолёт", "авиарейс", "перелет", "перелёт")
|
||||
}
|
||||
|
||||
func travelFollowUpCue(tokens []string) bool {
|
||||
|
||||
Reference in New Issue
Block a user