Compare commits

...
Author SHA1 Message Date
m d3ad1fd5bb Preserve distance context before search normalization
CI / test (push) Successful in 1m23s
CI / test (pull_request) Successful in 1m20s
2026-08-26 09:34:43 +03:00
m e1802aef12 Merge pull request 'Answer directly without repeating the question' (#399) from codex/household-direct-answer into main
CI / test (push) Successful in 1m34s
Reviewed-on: #399
2026-08-26 06:23:34 +00:00
m 35fc2438dc Answer directly without repeating the question
CI / test (push) Successful in 1m25s
CI / test (pull_request) Successful in 1m21s
2026-08-26 09:19:28 +03:00
m 70335a78e2 Merge pull request 'Fallback to local interpretation when provider fails' (#398) from codex/household-answer-quality into main
CI / test (push) Successful in 1m33s
Reviewed-on: #398
2026-08-26 06:05:14 +00:00
m 41067a0b16 Merge branch 'main' into codex/household-answer-quality
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m18s
2026-08-26 06:00:13 +00:00
m fba05d5ed4 Fallback to local interpretation when provider fails
CI / test (push) Failing after 56s
CI / test (pull_request) Failing after 56s
2026-08-26 08:59:49 +03:00
m 6e14ba1727 Merge pull request 'Review stable knowledge replies for conversational quality' (#397) from codex/household-answer-quality into main
CI / test (push) Successful in 2m0s
Reviewed-on: #397
2026-08-26 05:53:37 +00:00
m 9c6e2097b1 Review stable knowledge replies for conversational quality
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m27s
2026-08-26 08:48:01 +03:00
m 96559f6ab8 Merge pull request 'Preserve completed household answers in web chat' (#396) from codex/household-core-reply into main
CI / test (push) Successful in 1m25s
Reviewed-on: #396
2026-08-26 04:58:04 +00:00
m 46b89577eb Preserve completed household answers in web chat
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m21s
2026-08-26 05:21:53 +03:00
m a6ddbfaaf2 Merge pull request 'Handle household typos through semantic cognition' (#395) from codex/household-typo-cognition into main
CI / test (push) Successful in 1m29s
Reviewed-on: #395
2026-08-26 02:17:46 +00:00
m 362a1bd993 Handle household typos through semantic cognition
CI / test (push) Successful in 1m48s
CI / test (pull_request) Successful in 1m15s
2026-08-26 04:59:40 +03:00
m 735222de0d Merge pull request 'Route household knowledge and travel evidence through cognition' (#394) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m34s
Reviewed-on: #394
2026-08-26 01:51:16 +00:00
m 4e3d153557 Route household knowledge through cognition
CI / test (push) Successful in 1m50s
CI / test (pull_request) Successful in 1m16s
2026-08-26 04:31:18 +03:00
m f316f7ec3f Exclude dates from concrete research identifiers
CI / test (push) Successful in 1m21s
2026-08-26 04:20:15 +03:00
m da5d06895b Require destination evidence for ticket results
CI / test (push) Successful in 1m17s
2026-08-26 04:17:12 +03:00
m 4d1a579132 Merge pull request 'Keep source names out of identifier clarification' (#393) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m23s
Reviewed-on: #393
2026-08-26 01:12:36 +00:00
m 43836af511 Keep source names out of identifier clarification
CI / test (push) Successful in 1m15s
CI / test (pull_request) Successful in 1m37s
2026-08-26 03:34:00 +03:00
m 32457f7142 Merge pull request 'Explain missing concrete research identifiers' (#392) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m30s
Reviewed-on: #392
2026-08-26 00:31:26 +00:00
m 204eee489c Explain missing concrete research identifiers
CI / test (push) Successful in 1m15s
CI / test (pull_request) Successful in 1m16s
2026-08-26 03:20:17 +03:00
m 5cd8665aba Merge pull request 'Reject generic pages for concrete research identifiers' (#391) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m30s
Reviewed-on: #391
2026-08-26 00:17:03 +00:00
m 04018cbd07 Reject generic pages for concrete research identifiers
CI / test (push) Successful in 1m25s
CI / test (pull_request) Successful in 1m18s
2026-08-26 02:11:51 +03:00
m 23a1eeae56 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m12s
Reviewed-on: #390
2026-08-25 21:25:26 +00:00
m 369588ada7 Clean search imperatives before research
CI / test (push) Successful in 1m25s
CI / test (pull_request) Failing after 55s
2026-08-26 00:24:45 +03:00
m e323689fd4 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 21:19:35 +00:00
m af042dc69f Keep internet status replies concise
CI / test (push) Successful in 1m26s
CI / test (pull_request) Failing after 53s
2026-08-26 00:19:15 +03:00
m 60c1c31bd9 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 21:14:40 +00:00
m ff3f14fc77 Request time difference measurements explicitly
CI / test (push) Failing after 59s
CI / test (pull_request) Failing after 56s
2026-08-26 00:14:20 +03:00
m 36f991d8d0 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 21:11:50 +00:00
m 54a3996c17 Keep comparison entities in one request
CI / test (push) Failing after 59s
CI / test (pull_request) Failing after 52s
2026-08-26 00:11:30 +03:00
m a769f33d16 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 21:08:39 +00:00
m 056ff1b795 Research current time differences generically
CI / test (push) Failing after 1m0s
CI / test (pull_request) Failing after 57s
2026-08-26 00:08:19 +03:00
m 7b2debb60e Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 21:02:12 +00:00
m 6dfb3fc26c Validate displayed flight research excerpts
CI / test (push) Failing after 1m0s
CI / test (pull_request) Failing after 57s
2026-08-26 00:01:51 +03:00
m e0a093eaf3 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 20:58:11 +00:00
m 24916aa8f7 Require route evidence in displayed snippets
CI / test (push) Successful in 1m38s
CI / test (pull_request) Failing after 55s
2026-08-25 23:57:50 +03:00
m d7afd5be34 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 20:47:18 +00:00
m e104ce790d Require full route evidence for nearest flights
CI / test (push) Successful in 1m29s
CI / test (pull_request) Failing after 56s
2026-08-25 23:46:52 +03:00
m d7484cf757 Merge pull request 'Allow research source recovery to complete' (#390) from codex/household-travel-query-fix into main
Reviewed-on: #390
2026-08-25 20:43:03 +00:00
m a57e8ea71b Allow research source recovery to complete
CI / test (push) Failing after 56s
CI / test (pull_request) Failing after 1m4s
2026-08-25 23:42:33 +03:00
m 9dffde63b8 Merge pull request 'Form nearest flight searches from route context' (#389) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m16s
Reviewed-on: #389
2026-08-25 20:39:42 +00:00
m 4e5bb9abc9 Form nearest flight searches from route context
CI / test (push) Successful in 1m28s
CI / test (pull_request) Failing after 57s
2026-08-25 23:39:08 +03:00
m 3435de8013 Merge pull request 'Preserve honest research outcomes' (#388) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m13s
Reviewed-on: #388
2026-08-25 20:34:09 +00:00
m 76c2824887 Preserve attempted research outcomes
CI / test (push) Successful in 1m30s
CI / test (pull_request) Failing after 56s
2026-08-25 23:33:30 +03:00
m fc00a57942 Merge pull request 'Observe chat context route decisions' (#387) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m15s
Reviewed-on: #387
2026-08-25 20:30:19 +00:00
m 1b21888e8b Observe chat context route decisions
CI / test (push) Successful in 1m27s
CI / test (pull_request) Failing after 1m1s
2026-08-25 23:29:39 +03:00
m 012f5efafe Merge pull request 'Compile personal facts for contextual assistance' (#386) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m12s
Reviewed-on: #386
2026-08-25 19:51:47 +00:00
m eb842e24ff Compile personal facts for contextual assistance
CI / test (push) Successful in 1m15s
CI / test (pull_request) Failing after 50s
2026-08-25 22:50:37 +03:00
m 13bbcdf747 Preserve specialized research routes
CI / test (push) Successful in 1m19s
2026-08-25 22:46:20 +03:00
m c00b9b1549 Merge pull request 'Route plane requests through verified research' (#385) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m15s
Reviewed-on: #385
2026-08-25 19:42:26 +00:00
m 2d88d488f4 Route plane requests through verified research
CI / test (push) Successful in 1m34s
CI / test (pull_request) Failing after 58s
2026-08-25 22:42:03 +03:00
m 72c9cfc893 Merge pull request 'Validate flight research by route meaning' (#384) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m22s
Reviewed-on: #384
2026-08-25 19:35:41 +00:00
m 0b619bdc08 Validate flight research by route meaning
CI / test (push) Successful in 1m28s
CI / test (pull_request) Failing after 58s
2026-08-25 22:35:21 +03:00
m db23e984da Merge pull request 'Forward large generated images to chat' (#383) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m14s
Reviewed-on: #383
2026-08-25 19:31:17 +00:00
m f0c8f39986 Forward large generated images to chat
CI / test (push) Successful in 1m28s
CI / test (pull_request) Failing after 53s
2026-08-25 22:30:50 +03:00
m 5d8ec1995a Merge pull request 'Honor requested image quality' (#382) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m30s
Reviewed-on: #382
2026-08-25 19:14:21 +00:00
m 75d707e960 Honor requested image quality
CI / test (push) Successful in 1m22s
CI / test (pull_request) Successful in 1m25s
2026-08-25 22:12:44 +03:00
m 89b053b7bf Merge pull request 'Require assistant target for renaming' (#381) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m16s
Reviewed-on: #381
2026-08-25 18:32:39 +00:00
m e90aded6d3 Require assistant target for renaming
CI / test (push) Successful in 1m25s
CI / test (pull_request) Failing after 54s
2026-08-25 21:32:20 +03:00
m 8456ecd237 Merge pull request 'Continue ticket search with date follow-up' (#380) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m13s
Reviewed-on: #380
2026-08-25 18:21:30 +00:00
m b89fdef1d9 Merge remote-tracking branch 'origin/main' into codex/household-travel-query-fix
CI / test (push) Failing after 57s
CI / test (pull_request) Failing after 55s
2026-08-25 21:21:12 +03:00
m 6f3d576602 Continue ticket search with date follow-up
CI / test (push) Failing after 50s
CI / test (pull_request) Failing after 1m9s
2026-08-25 21:20:36 +03:00
m b026ec151e Merge pull request 'Preserve explicit memory confirmations' (#379) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m25s
Reviewed-on: #379
2026-08-25 18:16:05 +00:00
m 5d4c3877b1 Preserve explicit memory confirmations
CI / test (push) Successful in 1m14s
CI / test (pull_request) Successful in 1m26s
2026-08-25 21:13:26 +03:00
m 113c5ab965 Merge pull request 'Apply learned source aliases to research' (#378) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m14s
Reviewed-on: #378
2026-08-25 18:02:29 +00:00
m bce7993a6a Apply learned source aliases to research
CI / test (push) Successful in 1m30s
CI / test (pull_request) Failing after 52s
2026-08-25 21:02:07 +03:00
m 3bbf416c20 Merge pull request 'Explain missing research evidence' (#377) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m14s
Reviewed-on: #377
2026-08-25 17:56:42 +00:00
m b62b3b0dc3 Explain missing research evidence
CI / test (push) Failing after 59s
CI / test (pull_request) Failing after 1m2s
2026-08-25 20:56:21 +03:00
m 7e72474b0a Merge pull request 'Summarize verified internet connectivity' (#376) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m33s
Reviewed-on: #376
2026-08-25 17:53:22 +00:00
m b05081328e Recognize network connection wording
CI / test (push) Failing after 1m1s
CI / test (pull_request) Failing after 50s
2026-08-25 20:53:00 +03:00
m 0e7d1a7a50 Summarize verified internet connectivity
CI / test (push) Failing after 58s
2026-08-25 20:52:41 +03:00
m 2aaa73bb2a Merge pull request 'Describe local Domovoy capabilities' (#375) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m13s
Reviewed-on: #375
2026-08-25 17:50:03 +00:00
m 9000d90d72 Describe local Domovoy capabilities
CI / test (push) Successful in 1m29s
CI / test (pull_request) Failing after 1m0s
2026-08-25 20:49:39 +03:00
m 4afcfd3846 Merge pull request 'Recognize self capability questions' (#374) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m19s
Reviewed-on: #374
2026-08-25 16:54:32 +00:00
m 6ba8265686 Recognize self capability questions
CI / test (push) Failing after 58s
CI / test (pull_request) Failing after 54s
2026-08-25 19:54:06 +03:00
m 21583528c0 Merge pull request 'Explain current cognitive response route' (#373) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m18s
Reviewed-on: #373
2026-08-25 16:51:27 +00:00
m b1bb48f5b5 Explain current cognitive response route
CI / test (push) Successful in 1m40s
CI / test (pull_request) Failing after 54s
2026-08-25 19:51:03 +03:00
m 88f5f6e8f8 Merge pull request 'Recognize named model inventory requests' (#372) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m16s
Reviewed-on: #372
2026-08-25 16:48:12 +00:00
m 5439db2cc3 Recognize named model inventory requests
CI / test (push) Successful in 1m14s
CI / test (pull_request) Failing after 54s
2026-08-25 19:47:25 +03:00
m 208cb18ee0 Merge pull request 'Preserve latest travel context' (#371) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m32s
Reviewed-on: #371
2026-08-25 16:43:43 +00:00
m 5d19becdaf Preserve latest travel context
CI / test (push) Successful in 1m10s
CI / test (pull_request) Successful in 1m14s
2026-08-25 19:36:49 +03:00
m 4cd3ecdf15 Merge pull request 'Normalize contextual travel research' (#370) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m13s
Reviewed-on: #370
2026-08-25 16:29:58 +00:00
m 8e26f529f7 Merge remote-tracking branch 'origin/codex/household-travel-query-fix' into codex/household-travel-query-fix
CI / test (push) Failing after 1m3s
CI / test (pull_request) Failing after 54s
2026-08-25 19:28:11 +03:00
m 3cc636d6b7 Normalize contextual travel research 2026-08-25 19:27:59 +03:00
m 14dc439c82 Merge pull request 'Improve semantic travel research queries' (#369) from codex/household-travel-query-fix into main
CI / test (push) Successful in 1m14s
Reviewed-on: #369
2026-08-25 16:26:24 +00:00
m 21887988b2 Merge branch 'main' into codex/household-travel-query-fix
CI / test (push) Failing after 57s
CI / test (pull_request) Failing after 54s
2026-08-25 16:25:54 +00:00
m 232a57eed4 Improve semantic travel research queries
CI / test (push) Failing after 53s
CI / test (pull_request) Failing after 54s
2026-08-25 19:25:00 +03:00
m d1fd70e436 Merge pull request 'Preserve location context for travel follow-ups' (#368) from codex/household-travel-context-fix into main
CI / test (push) Successful in 1m30s
Reviewed-on: #368
2026-08-25 16:22:04 +00:00
m 1d73162df1 Merge remote-tracking branch 'origin/main' into codex/household-travel-context-fix
CI / test (push) Successful in 1m14s
CI / test (pull_request) Successful in 1m24s
2026-08-25 19:17:59 +03:00
m 7788cf4b44 Preserve location context for travel follow-ups
CI / test (push) Failing after 58s
CI / test (pull_request) Failing after 50s
2026-08-25 19:17:34 +03:00
m d9dcddbdbb Merge pull request 'Preserve direct time difference replies' (#367) from codex/household-time-reply-priority into main
CI / test (push) Successful in 2m3s
Reviewed-on: #367
2026-08-25 16:11:36 +00:00
m 5fb616310d Merge remote-tracking branch 'origin/main' into codex/household-time-reply-priority
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m24s
2026-08-25 19:08:15 +03:00
m 5a9cf7a22d Preserve direct time difference replies
CI / test (push) Failing after 51s
CI / test (pull_request) Failing after 48s
2026-08-25 19:06:51 +03:00
m d649f79ca4 Merge pull request 'Preserve remembered preference identity' (#366) from codex/household-checklist-live-preference-fix into main
CI / test (push) Successful in 1m58s
Reviewed-on: #366
2026-08-25 16:00:08 +00:00
22 changed files with 1371 additions and 131 deletions
+8 -2
View File
@@ -67,12 +67,18 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R
}
g.observe(r.Context(), selected, response, started)
if response.FinishReason != cognitive.FinishCompleted {
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: response.FinishReason, Usage: response.Usage})
// A selected provider can become unavailable after discovery. Semantic
// routing must still retain the deterministic interpretation and use the
// local lifeboat rather than turn an ordinary conversation into a
// degraded reply solely because one provider failed.
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
return
}
candidate, ok := parseInterpretationCandidate(response.AdvisoryText)
if !ok {
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishInvalidStructuredOutput, Usage: response.Usage})
// Structured interpretation is a transport contract. A malformed model
// answer is not a reason to discard the available local fallback.
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
return
}
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage})
@@ -1,9 +1,15 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
@@ -24,3 +30,32 @@ func TestBuiltinInterpretationTimeoutRetainsDeterministicCandidate(t *testing.T)
t.Fatalf("candidate did not retain deterministic meaning: %+v", response.Candidate)
}
}
func TestInterpretationProviderFailureUsesLocalLifeboat(t *testing.T) {
selected := gatewaySelection()
selected.Binding.DataPolicy = capability.DataPolicyLocalOnly
primary := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{FinishReason: cognitive.FinishProviderUnavailable}}
lifeboat := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{AdvisoryText: `{"primary_speech_act":"QUESTION","secondary_speech_act":"","confidence_millis":900,"applicability":"APPLICABLE","requested_outcome":"","needs_cognition":true,"clarification":"NOT_REQUIRED","selection_reason":"local lifeboat","capability_hints":["COGNITIVE_REASONING"]}`, FinishReason: cognitive.FinishCompleted}}
gateway := cognitiveGateway{
resolver: &registryStub{selected: selected},
providers: map[capability.ProviderKind][]providerRegistration{
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, Provider: primary}},
},
builtinLocal: &providerRegistration{Provider: lifeboat},
now: time.Now,
}
request := cognitive.GatewayInterpretationRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "сколько фигур в шахматах?", Locale: "ru", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, DeterministicSpeechAct: "QUESTION", DeterministicConfidence: 800, DeterministicApplicability: cognitive.InterpretationApplicable}
payload, _ := json.Marshal(request)
recorder := httptest.NewRecorder()
gateway.interpretationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/interpretations", bytes.NewReader(payload)))
if recorder.Code != http.StatusOK || !primary.called || !lifeboat.called {
t.Fatalf("status=%d primary=%t lifeboat=%t body=%s", recorder.Code, primary.called, lifeboat.called, recorder.Body.String())
}
var response cognitive.GatewayInterpretationResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.FinishReason != cognitive.FinishCompleted || response.Candidate.SelectionReason != "local lifeboat" {
t.Fatalf("response=%+v", response)
}
}
+37 -1
View File
@@ -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)
+21
View File
@@ -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"))
+8
View File
@@ -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")
}
}
+55 -3
View File
@@ -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
View File
@@ -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 "Сейчас не удалось обработать запрос."
+129 -5
View File
@@ -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 != "Канал мышления недоступен. Домовой остаётся на связи." {
+32
View File
@@ -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 {
+9 -2
View File
@@ -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 {
+20
View File
@@ -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 != "Текущий проект: Домовой" {
+48 -45
View File
@@ -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 {
+65 -12
View File
@@ -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.
+46
View File
@@ -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.
+173 -24
View File
@@ -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 {