diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go index 4da0ad8..2b61b28 100644 --- a/adapters/research/search_connector.go +++ b/adapters/research/search_connector.go @@ -36,19 +36,52 @@ var searchStopTerms = map[string]struct{}{ // cities, weather terms, entities or model names: the complete semantic query // comes from the interpretation layer. type SearchConnector struct { - Client *http.Client - EndpointTemplate string - AllowedHosts map[string]struct{} - MaxBytes int64 - MaxResults int + Client *http.Client + EndpointTemplate string + AllowedHosts map[string]struct{} + FallbackEndpoints []SearchEndpoint + MaxBytes int64 + MaxResults int +} + +// SearchEndpoint is an independently allowlisted web-search route. The +// connector tries routes in order and stops at the first relevant evidence. +// This is a source-recovery policy, not a question-type classifier. +type SearchEndpoint struct { + Template string + AllowedHosts map[string]struct{} } func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) { if request.Plan.Validate() != nil || strings.TrimSpace(request.Query) == "" || nowUnixMillis < 1 || !strings.Contains(c.EndpointTemplate, "%s") { return nil, ErrSearchRejected } - endpoint, err := url.Parse(strings.Replace(c.EndpointTemplate, "%s", url.QueryEscape(strings.TrimSpace(request.Query)), 1)) - if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !c.allowedHost(strings.ToLower(endpoint.Hostname())) { + endpoints := make([]SearchEndpoint, 0, 1+len(c.FallbackEndpoints)) + endpoints = append(endpoints, SearchEndpoint{Template: c.EndpointTemplate, AllowedHosts: c.AllowedHosts}) + endpoints = append(endpoints, c.FallbackEndpoints...) + var lastErr error + for _, candidateEndpoint := range endpoints { + results, err := c.collectEndpoint(ctx, candidateEndpoint, request, nowUnixMillis) + if err != nil { + lastErr = err + continue + } + if len(results) > 0 { + return results, nil + } + } + if lastErr != nil { + return nil, lastErr + } + return nil, nil +} + +func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchEndpoint, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) { + if !strings.Contains(configured.Template, "%s") { + return nil, ErrSearchRejected + } + endpoint, err := url.Parse(strings.Replace(configured.Template, "%s", url.QueryEscape(strings.TrimSpace(request.Query)), 1)) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !allowedHost(configured.AllowedHosts, strings.ToLower(endpoint.Hostname())) { return nil, ErrSearchRejected } client := c.Client @@ -138,7 +171,7 @@ func searchStatementRelatesToQuery(query, statement string) bool { return usable == 0 } -func (c SearchConnector) allowedHost(host string) bool { _, ok := c.AllowedHosts[host]; return ok } +func allowedHost(hosts map[string]struct{}, host string) bool { _, ok := hosts[host]; return ok } func (c SearchConnector) limit() int { if c.MaxResults <= 0 || c.MaxResults > 8 { return 3 diff --git a/adapters/research/search_connector_test.go b/adapters/research/search_connector_test.go new file mode 100644 index 0000000..1d5e098 --- /dev/null +++ b/adapters/research/search_connector_test.go @@ -0,0 +1,45 @@ +package research + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/epistemics" +) + +func TestSearchConnectorFallsBackToAnotherAllowedSource(t *testing.T) { + primary := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`
no usable results`)) + })) + defer primary.Close() + fallback := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`Казань: текущая температура 18 °CНаблюдается переменная облачность.`)) + })) + defer fallback.Close() + client := fallback.Client() + client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test servers only + request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan:test", Question: "какая погода в Казани", SourceClasses: []string{"web-search"}, FreshnessPolicy: string(epistemics.FreshnessCurrent)}, PropositionKey: "question:test", Query: "какая погода в Казани"} + connector := SearchConnector{ + Client: client, + EndpointTemplate: primary.URL + "?q=%s", + AllowedHosts: map[string]struct{}{strings.TrimPrefix(primary.URL, "https://"): {}}, + FallbackEndpoints: []SearchEndpoint{{ + Template: fallback.URL + "?q=%s", + AllowedHosts: map[string]struct{}{strings.TrimPrefix(fallback.URL, "https://"): {}}, + }}, + } + // The allowlist is hostname-only; test servers include an ephemeral port. + connector.AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(primary.URL, "https://"), ":")[0]: {}} + connector.FallbackEndpoints[0].AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(fallback.URL, "https://"), ":")[0]: {}} + results, err := connector.Collect(context.Background(), request, 1) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || !strings.Contains(results[0].Statement, "18") { + t.Fatalf("fallback evidence = %#v", results) + } +} diff --git a/cmd/creator-web/chat_test.go b/cmd/creator-web/chat_test.go index 5f3723d..ab68d5a 100644 --- a/cmd/creator-web/chat_test.go +++ b/cmd/creator-web/chat_test.go @@ -165,7 +165,14 @@ func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) { })) defer core.Close() cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unverified question must not reach advisory: %s %s", r.Method, r.URL.Path) + if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" { + t.Fatalf("unexpected cognitive route: %s %s", r.Method, r.URL.Path) + } + var request cognitive.GatewayAdvisoryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я продолжаю искать проверяемый ответ через доступный источник.", FinishReason: cognitive.FinishCompleted}) })) defer cognitiveRuntime.Close() handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL) @@ -184,8 +191,8 @@ func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) { if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatal(err) } - if response.Cognition != "verification_required" || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "не буду придумывать") { - t.Fatalf("unexpected truth-first response: %#v", response) + if response.Cognition != string(cognitive.FinishCompleted) || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "продолжаю искать") { + t.Fatalf("unexpected research recovery response: %#v", response) } } diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index ab3faf9..1c6483f 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -468,11 +468,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { saved.NeedsCognition = false } } - if requiresVerifiedQuestion(saved, browserRequest.Message) { - reply = truthfulVerificationRequiredReply() - cognition = "verification_required" - saved.NeedsCognition = false - } else if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired { + if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired { cognition = "unavailable" requestID, idErr := ids.New() if idErr == nil { @@ -514,30 +510,19 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { }) } -func requiresVerifiedQuestion(saved interaction.ChatMessageResponse, message string) bool { - if saved.SpeechAct != interaction.SpeechActQuestion || saved.InformationRequestKind != "" || saved.ResearchBacked || saved.WorkPlanned || saved.Clarification == interaction.ClarificationRequired || asksAttention(message) { - return false - } - for _, hint := range saved.CapabilityHints { - if hint == capability.CapabilityKindWebResearch || hint == capability.CapabilityKindVerifyFact { - return true - } - } - return false -} - -func truthfulVerificationRequiredReply() string { - return "Я не могу сейчас подтвердить этот факт по проверенному источнику, поэтому не буду придумывать ответ. Для проверки используйте официальный сайт соответствующей службы или надёжный сервис с текущими данными; когда подключён research-маршрут, Домовой сможет проверить это сам." -} - func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt { - if strings.TrimSpace(saved.ActiveProjectName) == "" { - return []cognitive.ContextExcerpt{} - } - bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{ - DataPolicy: capability.DataPolicyLocalOnly, - MaxBytes: 2048, - Candidates: []cognitive.ContextCandidate{{ + candidates := []cognitive.ContextCandidate{{ + Excerpt: cognitive.ContextExcerpt{ + ID: "assistant-identity", + SourceKind: "assistant-identity-policy", + SourceID: "domovoy", + Sensitivity: cognitive.SensitivityPublic, + Text: "Ты — Домовой, локальный помощник пользователя. Отвечай от имени Домового. Не называй себя Qwen, Alibaba Cloud, поставщиком модели или внутренним runtime. Если спрашивают имя, скажи: «Меня зовут Домовой». Твоя задача — понять смысл запроса, найти путь к цели и довести помощь до полезного конечного результата; не ограничивайся пересказом отказа модели.", + }, + Relevance: 100, + }} + if strings.TrimSpace(saved.ActiveProjectName) != "" { + candidates = append(candidates, cognitive.ContextCandidate{ Excerpt: cognitive.ContextExcerpt{ ID: cognitive.ContextExcerptID("active-project-focus"), SourceKind: "situation-active-project", @@ -545,8 +530,13 @@ func localChatContext(saved interaction.ChatMessageResponse) []cognitive.Context Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName), }, - Relevance: 100, - }}, + Relevance: 90, + }) + } + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{ + DataPolicy: capability.DataPolicyLocalOnly, + MaxBytes: 2048, + Candidates: candidates, }) if err != nil { return []cognitive.ContextExcerpt{} diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index f3c01d4..6804a71 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -13,13 +13,13 @@ import ( "git.cin.su/m/Assistant/contracts/interaction" ) -func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) { +func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) { context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"}) - if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" { + if len(context) != 2 || context[0].SourceID != "domovoy" || !strings.Contains(context[0].Text, "Ты — Домовой") || context[1].Text != "Текущий проект: Домовой" { t.Fatalf("unexpected local context: %#v", context) } - if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 { - t.Fatalf("expected empty context without active project: %#v", empty) + if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 1 || empty[0].SourceID != "domovoy" { + t.Fatalf("identity context must remain available without active project: %#v", empty) } } diff --git a/cmd/research-runtime/main.go b/cmd/research-runtime/main.go index 606d4d8..8c6a737 100644 --- a/cmd/research-runtime/main.go +++ b/cmd/research-runtime/main.go @@ -89,7 +89,16 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) }) - searchConnector := researchadapter.SearchConnector{EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"), AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")), Client: &http.Client{Timeout: 8 * time.Second}, MaxResults: 3} + searchConnector := researchadapter.SearchConnector{ + EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"), + AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")), + FallbackEndpoints: []researchadapter.SearchEndpoint{{ + Template: "https://html.duckduckgo.com/html/?q=%s", + AllowedHosts: map[string]struct{}{"html.duckduckgo.com": {}}, + }}, + Client: &http.Client{Timeout: 8 * time.Second}, + MaxResults: 3, + } mux.HandleFunc("POST /v1/research/web/search", func(w http.ResponseWriter, r *http.Request) { var request epistemics.WebSearchRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil { diff --git a/dev/tasks/DEV-000292-development-mode-runtime-access.json b/dev/tasks/DEV-000292-development-mode-runtime-access.json index 56db178..0113e26 100644 --- a/dev/tasks/DEV-000292-development-mode-runtime-access.json +++ b/dev/tasks/DEV-000292-development-mode-runtime-access.json @@ -12,7 +12,9 @@ "cmd/cognitive-worker/**", "cmd/core-runtime/**", "cmd/creator-web/**", + "cmd/research-runtime/**", "adapters/models/**", + "adapters/research/**", "contracts/cognitive/**", "modules/interaction/domain/**", "deploy/docker-compose.dev.yml",