diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go index 986d73e..4da0ad8 100644 --- a/adapters/research/search_connector.go +++ b/adapters/research/search_connector.go @@ -24,8 +24,14 @@ var ( bingLinkPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?]*>]*>(.*?)`) bingSnippetPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?]*>(.*?)

`) tagPattern = regexp.MustCompile(`(?s)<[^>]*>`) + termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`) ) +var searchStopTerms = map[string]struct{}{ + "какая": {}, "какое": {}, "какие": {}, "какой": {}, "что": {}, "как": {}, "где": {}, "когда": {}, "почему": {}, "мне": {}, "пожалуйста": {}, + "what": {}, "which": {}, "where": {}, "when": {}, "why": {}, "how": {}, "please": {}, "the": {}, "this": {}, +} + // SearchConnector uses a configured HTTPS search endpoint. It does not know // cities, weather terms, entities or model names: the complete semantic query // comes from the interpretation layer. @@ -91,7 +97,7 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear snippet = cleanHTML(string(snippets[index][1])) } statement := strings.TrimSpace(strings.Trim(title+" — "+snippet, " —")) - if statement == "" { + if statement == "" || !searchStatementRelatesToQuery(request.Query, statement) { continue } ordinal := strconv.Itoa(index) @@ -108,6 +114,30 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear return results, nil } +// searchStatementRelatesToQuery is a bounded relevance gate, not a truth +// check. It prevents generic search pages from becoming apparent evidence +// when their content has no substantive relation to the requested query. +func searchStatementRelatesToQuery(query, statement string) bool { + terms := termPattern.FindAllString(strings.ToLower(query), -1) + content := strings.ToLower(statement) + usable := 0 + for _, term := range terms { + if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 { + continue + } + usable++ + runes := []rune(term) + prefixLen := 5 + if len(runes) < prefixLen { + prefixLen = len(runes) + } + if strings.Contains(content, string(runes[:prefixLen])) { + return true + } + } + return usable == 0 +} + func (c SearchConnector) allowedHost(host string) bool { _, ok := c.AllowedHosts[host]; return ok } func (c SearchConnector) limit() int { if c.MaxResults <= 0 || c.MaxResults > 8 { diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 1d04ab2..397b712 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000285-semantic-research-routing-fix.json", - "task_id": "DEV-000285", + "task_file": "dev/tasks/DEV-000286-research-result-relevance-filter.json", + "task_id": "DEV-000286", "task_revision": 1 } diff --git a/dev/tasks/DEV-000286-research-result-relevance-filter.json b/dev/tasks/DEV-000286-research-result-relevance-filter.json new file mode 100644 index 0000000..8500bbe --- /dev/null +++ b/dev/tasks/DEV-000286-research-result-relevance-filter.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000286", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "b0a8b3e", + "risk_tier": "E", + "intent": "Reject web-search excerpts with no substantive lexical relation to the requested query so truth-first chat never presents irrelevant search results as useful evidence.", + "requirement_ids": ["research relevance boundary", "truth-first no irrelevant evidence", "no entity-specific filtering"], + "allowed_change_scope": {"modules": ["research adapter"], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000286-research-result-relevance-filter.json", "adapters/research/search_connector.go", "tests/research_runtime/search_connector_test.go"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "cmd/**", "deploy/**", "LLM-local/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["epistemics ResearchCandidate", "provider-neutral WebSearchRequest"], "may_change": []}, + "semantic_owner": "epistemics research adapter boundary", + "canonical_state": ["no new canonical state; irrelevant candidates are not emitted"], + "state_transitions": ["raw search result -> relevance gate -> candidate or omission"], + "side_effects_and_privileges": ["read-only search connector only"], + "persistence_and_migrations": ["none"], + "failure_retry_restart": ["zero relevant candidates becomes the existing truthful no-evidence response"], + "assumptions": ["short query terms without substantive tokens retain prior parser behavior for compatibility", "term overlap is a bounded relevance guard, not semantic truth verification"], + "acceptance_scenarios": ["irrelevant dictionary results are omitted for a weather-like query", "relevant result is retained", "no entity-specific branch exists"], + "required_tests": ["go test ./tests/research_runtime", "go vet ./adapters/research ./tests/research_runtime", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke", "one live chat smoke"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/tests/research_runtime/search_connector_test.go b/tests/research_runtime/search_connector_test.go index 8596fbc..0c3a7e1 100644 --- a/tests/research_runtime/search_connector_test.go +++ b/tests/research_runtime/search_connector_test.go @@ -16,7 +16,7 @@ func TestSearchConnectorReturnsBoundedImmutableEvidence(t *testing.T) { _, _ = w.Write([]byte(`Verified titleObserved source excerpt`)) })) defer server.Close() - request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "current fact", SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, PropositionKey: "question-1", Query: "current fact"} + request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "verified source", SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, PropositionKey: "question-1", Query: "verified source"} connector := researchadapter.SearchConnector{EndpointTemplate: server.URL + "?q=%s", AllowedHosts: map[string]struct{}{"127.0.0.1": {}}, Client: server.Client(), MaxResults: 3} // The production connector requires HTTPS. The test server is HTTPS too; // use its host as the allowlisted value and preserve the same transport. @@ -52,3 +52,19 @@ func TestSearchConnectorParsesConfiguredBingStyleResults(t *testing.T) { t.Fatalf("unexpected Bing-style evidence: candidates=%+v err=%v", candidates, err) } } + +func TestSearchConnectorDropsIrrelevantResults(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`
  • Викисловарь

    Статья о грамматике

  • `)) + })) + defer server.Close() + request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "какая погода в Киеве", SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, PropositionKey: "p", Query: "какая погода в Киеве"} + connector := researchadapter.SearchConnector{EndpointTemplate: server.URL + "?q=%s", AllowedHosts: map[string]struct{}{"127.0.0.1": {}}, Client: server.Client(), MaxResults: 3} + candidates, err := connector.Collect(context.Background(), request, 10) + if err != nil { + t.Fatalf("collect search evidence: %v", err) + } + if len(candidates) != 0 { + t.Fatalf("irrelevant result was retained: %+v", candidates) + } +}