Complete conversational research fallback #277

Merged
m merged 2 commits from codex/chat-semantic-inventory into main 2026-08-24 18:36:25 +00:00
7 changed files with 132 additions and 46 deletions
+41 -8
View File
@@ -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
@@ -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(`<html><body>no usable results</body></html>`))
}))
defer primary.Close()
fallback := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<a class="result__a" href="https://example.test">Казань: текущая температура 18 °C</a><a class="result__snippet">Наблюдается переменная облачность.</a>`))
}))
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)
}
}
+10 -3
View File
@@ -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)
}
}
+20 -30
View File
@@ -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{}
+4 -4
View File
@@ -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)
}
}
+10 -1
View File
@@ -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 {
@@ -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",