diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go
new file mode 100644
index 0000000..a3dc1d4
--- /dev/null
+++ b/adapters/research/search_connector.go
@@ -0,0 +1,114 @@
+package research
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "html"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "git.cin.su/m/Assistant/contracts/epistemics"
+)
+
+var ErrSearchRejected = errors.New("web search request rejected")
+
+var (
+ searchLinkPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*result__a[^"']*["'][^>]*>(.*?)`)
+ searchSnippetPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)`)
+ tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
+)
+
+// 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.
+type SearchConnector struct {
+ Client *http.Client
+ EndpointTemplate string
+ AllowedHosts map[string]struct{}
+ MaxBytes int64
+ MaxResults int
+}
+
+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())) {
+ return nil, ErrSearchRejected
+ }
+ client := c.Client
+ if client == nil {
+ client = &http.Client{}
+ }
+ maxBytes := c.MaxBytes
+ if maxBytes <= 0 || maxBytes > 4<<20 {
+ maxBytes = 512 << 10
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return nil, ErrSearchRejected
+ }
+ response, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, ErrSearchRejected
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
+ if err != nil || len(body) == 0 || int64(len(body)) > maxBytes {
+ return nil, ErrSearchRejected
+ }
+ links := searchLinkPattern.FindAllSubmatch(body, c.limit())
+ snippets := searchSnippetPattern.FindAllSubmatch(body, c.limit())
+ if len(links) == 0 {
+ return nil, nil
+ }
+ digest := sha256.Sum256(body)
+ digestText := "sha256:" + hex.EncodeToString(digest[:])
+ base := hex.EncodeToString(digest[:])
+ sourceID := epistemics.SourceID("source:search:" + base)
+ snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
+ results := make([]epistemics.ResearchCandidate, 0, len(links))
+ for index, link := range links {
+ title := cleanHTML(string(link[1]))
+ snippet := ""
+ if index < len(snippets) {
+ snippet = cleanHTML(string(snippets[index][1]))
+ }
+ statement := strings.TrimSpace(strings.Trim(title+" — "+snippet, " —"))
+ if statement == "" {
+ continue
+ }
+ ordinal := strconv.Itoa(index)
+ excerptID := epistemics.SourceExcerptID("excerpt:search:" + base + ":" + ordinal)
+ results = append(results, epistemics.ResearchCandidate{
+ PropositionKey: request.PropositionKey + ":result:" + ordinal,
+ Statement: statement,
+ Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: endpoint.String(), Title: title, Publisher: endpoint.Hostname()},
+ Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: sourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
+ Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))},
+ Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
+ })
+ }
+ return results, nil
+}
+
+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 {
+ return 3
+ }
+ return c.MaxResults
+}
+func cleanHTML(value string) string {
+ return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ")
+}
diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go
index 8c7f751..fb8bdc1 100644
--- a/cmd/core-runtime/cognitive_interpretation.go
+++ b/cmd/core-runtime/cognitive_interpretation.go
@@ -61,13 +61,6 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
}
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
- // The built-in advisory channel is the default conversational path. A
- // simple question must not first wait for the slower, strict JSON
- // interpretation route: that route exists for ambiguity and work routing,
- // not as a prerequisite for talking to Domovoy.
- if isSimpleLocalQuestion(result) {
- return false
- }
switch result.Interpretation.Applicability {
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
return true
diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go
index b530085..99223f8 100644
--- a/cmd/core-runtime/cognitive_interpretation_test.go
+++ b/cmd/core-runtime/cognitive_interpretation_test.go
@@ -7,7 +7,7 @@ import (
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
-func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing.T) {
+func TestSimpleQuestionUsesStrictInterpretationForSemanticRouting(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActQuestion,
@@ -16,8 +16,8 @@ func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing
},
CapabilityHints: []string{"COGNITIVE_REASONING", "VERIFY_FACT"},
}
- if shouldRequestBroaderInterpretation(result) {
- t.Fatal("simple question must go directly to the built-in advisory path")
+ if !shouldRequestBroaderInterpretation(result) {
+ t.Fatal("simple question must reach semantic interpretation before any answer")
}
}
diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go
index a48c176..104885c 100644
--- a/cmd/core-runtime/main.go
+++ b/cmd/core-runtime/main.go
@@ -22,6 +22,7 @@ import (
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
workdomain "git.cin.su/m/Assistant/modules/work/domain"
+ "git.cin.su/m/Assistant/platform/ids"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
"github.com/jackc/pgx/v5"
@@ -621,6 +622,17 @@ func main() {
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
needsCognition = false
}
+ researchBacked := false
+ if hasCapabilityHint(semantic.CapabilityHints, "WEB_RESEARCH") && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
+ if planID, idErr := ids.New(); idErr == nil {
+ result, researchErr := researchClient.SearchWeb(r.Context(), epistemics.WebSearchRequest{
+ Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID("plan:" + planID), Question: utterance.Content, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
+ PropositionKey: "question:" + string(utterance.ID), Query: utterance.Content,
+ })
+ planningReply, researchBacked = renderResearchReply(result, researchErr)
+ needsCognition = false
+ }
+ }
if helpPath.Reply != "" {
planningReply = helpPath.Reply
needsCognition = false
@@ -718,6 +730,7 @@ func main() {
Clarification: semantic.Interpretation.Clarification,
WorkPlanned: workPlanned,
PlanningReply: planningReply,
+ ResearchBacked: researchBacked,
CapabilityStatus: capabilityStatus,
CapabilityKind: capabilityKind,
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
@@ -1427,6 +1440,32 @@ func envInt(name string, fallback int) int {
return value
}
+func hasCapabilityHint(hints []string, wanted string) bool {
+ for _, hint := range hints {
+ if hint == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+func renderResearchReply(result epistemics.ResearchResult, researchErr error) (string, bool) {
+ if researchErr != nil {
+ return "Я не смог получить проверяемые данные из research-источника, поэтому не буду придумывать ответ.", false
+ }
+ lines := make([]string, 0, len(result.Findings))
+ for _, finding := range result.Findings {
+ if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
+ continue
+ }
+ lines = append(lines, "- "+finding.Excerpt.Text+"\n Источник: "+finding.Source.Locator)
+ }
+ if len(lines) == 0 {
+ return "Я не получил достаточно согласованных данных из проверяемых источников, поэтому не буду выдавать предположение как факт.", false
+ }
+ return "Нашёл точные выдержки из подключённого источника. Я не заменяю их догадкой:\n" + strings.Join(lines, "\n"), true
+}
+
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
if inventoryErr != nil {
return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
diff --git a/cmd/core-runtime/research_client.go b/cmd/core-runtime/research_client.go
index 4b992a9..c281661 100644
--- a/cmd/core-runtime/research_client.go
+++ b/cmd/core-runtime/research_client.go
@@ -51,3 +51,28 @@ func (c researchRuntimeClient) EvaluateWeb(ctx context.Context, request epistemi
}
return result, nil
}
+
+func (c researchRuntimeClient) SearchWeb(ctx context.Context, request epistemics.WebSearchRequest) (epistemics.ResearchResult, error) {
+ payload, err := json.Marshal(request)
+ if err != nil {
+ return epistemics.ResearchResult{}, err
+ }
+ httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/search", bytes.NewReader(payload))
+ if err != nil {
+ return epistemics.ResearchResult{}, err
+ }
+ httpRequest.Header.Set("Content-Type", "application/json")
+ response, err := c.client.Do(httpRequest)
+ if err != nil {
+ return epistemics.ResearchResult{}, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return epistemics.ResearchResult{}, fmt.Errorf("research search status %d", response.StatusCode)
+ }
+ var result epistemics.ResearchResult
+ if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+ return epistemics.ResearchResult{}, err
+ }
+ return result, nil
+}
diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go
index 2077528..9017013 100644
--- a/cmd/creator-web/server.go
+++ b/cmd/creator-web/server.go
@@ -209,6 +209,7 @@ type browserChatResponse struct {
CapabilityKind string `json:"capability_kind"`
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
RequestedOutcome string `json:"requested_outcome,omitempty"`
+ ResearchBacked bool `json:"research_backed"`
InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"`
DelegationID string `json:"delegation_id"`
DelegationState string `json:"delegation_state"`
@@ -467,7 +468,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
saved.NeedsCognition = false
}
}
- if saved.SpeechAct == interaction.SpeechActQuestion && saved.InformationRequestKind == "" && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired && !asksAttention(browserRequest.Message) {
+ if saved.SpeechAct == interaction.SpeechActQuestion && saved.InformationRequestKind == "" && !saved.ResearchBacked && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired && !asksAttention(browserRequest.Message) {
reply = truthfulVerificationRequiredReply()
cognition = "verification_required"
saved.NeedsCognition = false
@@ -505,6 +506,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
CapabilityKind: string(saved.CapabilityKind),
CapabilityHints: saved.CapabilityHints,
RequestedOutcome: saved.RequestedOutcome,
+ ResearchBacked: saved.ResearchBacked,
InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis,
DelegationID: saved.DelegationID,
DelegationState: saved.DelegationState,
diff --git a/cmd/research-runtime/main.go b/cmd/research-runtime/main.go
index d2b165b..606d4d8 100644
--- a/cmd/research-runtime/main.go
+++ b/cmd/research-runtime/main.go
@@ -89,6 +89,26 @@ 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}
+ 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 {
+ http.Error(w, "invalid web search request", http.StatusBadRequest)
+ return
+ }
+ candidates, err := searchConnector.Collect(r.Context(), request, time.Now().UnixMilli())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+ return
+ }
+ result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: candidates}, repository, time.Now().UnixMilli())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ })
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8087"
diff --git a/contracts/epistemics/research.go b/contracts/epistemics/research.go
index e961d8a..6231110 100644
--- a/contracts/epistemics/research.go
+++ b/contracts/epistemics/research.go
@@ -96,6 +96,8 @@ type ResearchFinding struct {
Statement string
Claim Claim
Citations []Citation
+ Source Source
+ Excerpt SourceExcerpt
State ResearchFindingState
Reason string
}
@@ -131,6 +133,15 @@ type WebResearchRequest struct {
Statement string
}
+// WebSearchRequest asks the research runtime to discover bounded source
+// evidence for a human question. The provider is configured by the runtime;
+// the caller supplies meaning, not a provider URL or model choice.
+type WebSearchRequest struct {
+ Plan ResearchPlan
+ PropositionKey string
+ Query string
+}
+
// ResolveCitation creates evidence only when all identifiers form one exact
// source chain. It deliberately does not fetch, infer or rewrite source text.
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
diff --git a/contracts/interaction/types.go b/contracts/interaction/types.go
index 3a28841..1597c64 100644
--- a/contracts/interaction/types.go
+++ b/contracts/interaction/types.go
@@ -173,6 +173,7 @@ type ChatMessageResponse struct {
Clarification ClarificationState
WorkPlanned bool
PlanningReply string
+ ResearchBacked bool
CapabilityStatus capability.CapabilityResolutionStatus
CapabilityKind capability.CapabilityKind
CapabilityHints []capability.CapabilityKind
diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml
index a425a94..84e0169 100644
--- a/deploy/docker-compose.prod.yml
+++ b/deploy/docker-compose.prod.yml
@@ -195,6 +195,8 @@ services:
LISTEN_ADDR: :8087
RESEARCH_SOURCE_ROOT: /var/lib/research-sources
RESEARCH_HTTP_ALLOWED_HOSTS: assistant.cin.su,git.cin.su
+ RESEARCH_SEARCH_URL_TEMPLATE: ${RESEARCH_SEARCH_URL_TEMPLATE:-https://html.duckduckgo.com/html/?q=%s}
+ RESEARCH_SEARCH_ALLOWED_HOSTS: ${RESEARCH_SEARCH_ALLOWED_HOSTS:-html.duckduckgo.com}
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json
index ad0b5d8..17f3ba5 100644
--- a/dev/ACTIVE_TASK.json
+++ b/dev/ACTIVE_TASK.json
@@ -1,5 +1,5 @@
{
- "task_file": "dev/tasks/DEV-000274-truth-first-question-gate.json",
- "task_id": "DEV-000274",
+ "task_file": "dev/tasks/DEV-000275-semantic-web-research-routing.json",
+ "task_id": "DEV-000275",
"task_revision": 1
}
diff --git a/dev/tasks/DEV-000275-semantic-web-research-routing.json b/dev/tasks/DEV-000275-semantic-web-research-routing.json
new file mode 100644
index 0000000..a660b84
--- /dev/null
+++ b/dev/tasks/DEV-000275-semantic-web-research-routing.json
@@ -0,0 +1,23 @@
+{
+ "task_id": "DEV-000275",
+ "task_revision": 1,
+ "architecture_revision": "ADR-0018",
+ "base_repo_revision": "1eb3e39",
+ "risk_tier": "E",
+ "intent": "Route semantically classified WEB_RESEARCH questions through the bounded research runtime and present only immutable source excerpts; absence or conflict of evidence remains a truthful non-answer.",
+ "requirement_ids": ["semantic request classification", "truth-first default", "source-backed response", "optional external channel"],
+ "allowed_change_scope": {"modules": ["core-runtime", "creator-web", "cognitive-worker", "research-runtime", "epistemics", "interaction", "deployment"], "paths": ["contracts/epistemics/research.go", "contracts/interaction/types.go", "adapters/research/search_connector.go", "modules/epistemics/domain/research.go", "cmd/research-runtime/main.go", "cmd/core-runtime/main.go", "cmd/core-runtime/research_client.go", "cmd/core-runtime/cognitive_interpretation.go", "cmd/core-runtime/cognitive_interpretation_test.go", "cmd/creator-web/server.go", "deploy/docker-compose.prod.yml", "tests/research_runtime/search_connector_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000275-semantic-web-research-routing.json", "docs/PROJECT_STATUS.md", "docs/evidence/**"]},
+ "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "LLM-local/**", "modules/interaction/domain/**"]},
+ "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
+ "contracts": {"consume": ["existing semantic interpretation", "existing epistemics evidence chain", "existing research runtime persistence"], "may_change": ["typed web-search request and source-backed finding presentation"]},
+ "semantic_owner": "interaction/cognitive interpretation selects meaning; epistemics owns evidence; Core presents the result",
+ "canonical_state": ["epistemics truth bundle and citation state remain canonical", "no generated advisory is canonical evidence"],
+ "state_transitions": ["semantic WEB_RESEARCH -> bounded search request -> persisted source snapshot -> source-backed chat response", "search unavailable/conflicted -> verification-required response"],
+ "side_effects_and_privileges": ["read-only HTTPS search fetches to configured allowlist", "no provider/model selection from user input", "no secret forwarded to search"],
+ "persistence_and_migrations": ["existing epistemics persistence only; no migration"],
+ "failure_retry_restart": ["empty/unavailable search never becomes an answer", "runtime restart preserves persisted evidence"],
+ "assumptions": ["search endpoint template and host allowlist are operator configuration", "local model remains optional; deterministic truth gate is the fallback"],
+ "acceptance_scenarios": ["simple factual question reaches semantic interpretation", "generic question is routed by WEB_RESEARCH without city/entity keyword lists", "bounded source excerpt and locator are returned", "conflicted/empty evidence is not presented as fact", "search channel can be absent without breaking chat core"],
+ "required_tests": ["go test ./adapters/research ./modules/epistemics/... ./cmd/research-runtime/... ./cmd/core-runtime/... ./cmd/creator-web/... ./tests/research_runtime/...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent", "git diff --check", "CI", "production health and research-runtime readiness smoke"],
+ "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY"]
+}
diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md
index b427c71..39f37cd 100644
--- a/docs/PROJECT_STATUS.md
+++ b/docs/PROJECT_STATUS.md
@@ -16,6 +16,7 @@
- `ADDITIONAL_EVALUATION_STATUS = LIVE_TYPED_HANDOFF`; Capability-owned unresolved evaluation decisions can now be handed to Work through a typed production endpoint. Work preserves parent evidence lineage and schedules the bounded additional profile through the existing durable graph/dispatch path; model/provider selection remains outside Work. Evidence: `docs/evidence/additional-evaluation-runtime-live-2026-08-23-v1.json` and DEV-000261.
- `FLEET_EXECUTION_EVIDENCE = docs/evidence/audit-runtime-status-2026-08-23-v1.json`; FAST, MEDIUM and LIFEBOAT are ready, HEAVY is explicitly degraded by provider timeout, FAST remains preferred/selected, and LIFEBOAT is the fallback tier.
- `TRUTH_RESEARCH_STATUS = LIVE_CANARY_PERSISTED`; the web research canary preserved an OBSERVED/COMPLETE/NO-CONFLICT source chain while leaving the claim UNVERIFIED and belief PROVISIONAL. Evidence: `docs/evidence/truth-runtime-canary-2026-08-23-v1.json`.
+- `TRUTH_FIRST_CHAT_STATUS = IMPLEMENTED_NOT_DEPLOYED`; factual questions now require strict semantic routing to optional `WEB_RESEARCH` before any answer, and only supported source excerpts/locators can be presented. Evidence: `docs/evidence/semantic-web-research-routing-2026-08-24-v1.json`.
- `LEARNING_REFLECTION_STATUS = LIVE_RECONCILED`; reflection worker returned no pending candidate and the durable state contains 31 RESOLVED candidates with no OPEN backlog. Evidence: `docs/evidence/reflection-runtime-reconciliation-2026-08-23-v1.json`.
- `BACKUP_RESTORE_STATUS = VERIFIED`; latest checksum and isolated restore evidence is retained in `docs/evidence/wp-002-postgres-restore-2026-08-22-v1.json`.
- `GOVERNED_SELF_DEVELOPMENT_STATUS = DURABLE_GATE_VERIFIER_AND_TRANSITIONS_LIVE`; Work-owned revisioned gate storage, independent local verification, and authenticated optimistic-concurrency transitions for canary/promotion/rollback are live. Source mutation and external bridge execution remain unclaimed. Evidence: `docs/evidence/governed-development-gate-persistence-2026-08-23-v1.json` and DEV-000254 CI/live-smoke records.
diff --git a/docs/evidence/semantic-web-research-routing-2026-08-24-v1.json b/docs/evidence/semantic-web-research-routing-2026-08-24-v1.json
new file mode 100644
index 0000000..2f690e2
--- /dev/null
+++ b/docs/evidence/semantic-web-research-routing-2026-08-24-v1.json
@@ -0,0 +1,20 @@
+{
+ "artifact_id": "semantic-web-research-routing-2026-08-24-v1",
+ "task_id": "DEV-000275",
+ "repository_revision": "working-tree-after-1eb3e39",
+ "status": "IMPLEMENTED_NOT_DEPLOYED",
+ "facts": [
+ "Simple factual questions now reach strict semantic interpretation before any advisory answer.",
+ "WEB_RESEARCH is a semantic capability hint; the route contains no city, weather, entity or model-name list.",
+ "Search uses an operator-configured HTTPS endpoint and host allowlist, then persists immutable digest, snapshot and excerpt evidence through the existing epistemics owner.",
+ "Core presents exact source excerpts and locators only for supported findings; empty, stale or conflicted evidence remains a truthful non-answer.",
+ "The search channel is optional and does not prevent the local chat core from operating."
+ ],
+ "tests": [
+ "go test ./adapters/research ./modules/epistemics/... ./cmd/research-runtime/... ./cmd/core-runtime/... ./cmd/creator-web/... ./tests/research_runtime/...",
+ "go vet ./...",
+ "devctl preflight/check-diff/check-architecture/independent",
+ "git diff --check"
+ ],
+ "limitation": "Production deployment and live assistant.cin.su source-backed chat verification are pending the normal Portainer deployment access."
+}
diff --git a/modules/epistemics/domain/research.go b/modules/epistemics/domain/research.go
index 436e6ff..62efc3f 100644
--- a/modules/epistemics/domain/research.go
+++ b/modules/epistemics/domain/research.go
@@ -57,7 +57,7 @@ func evaluateGroup(key string, candidates []epistemics.ResearchCandidate, evalua
if err != nil {
return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate
}
- finding := epistemics.ResearchFinding{PropositionKey: key, Statement: first.Statement, Claim: firstBundle.Claim, Citations: []epistemics.Citation{firstBundle.Citation}, State: epistemics.FindingSupported}
+ finding := epistemics.ResearchFinding{PropositionKey: key, Statement: first.Statement, Claim: firstBundle.Claim, Citations: []epistemics.Citation{firstBundle.Citation}, Source: first.Source, Excerpt: first.Excerpt, State: epistemics.FindingSupported}
statements := map[string]struct{}{normalize(first.Statement): {}}
if first.Snapshot.Freshness == epistemics.FreshnessUnknown || first.Evidence == epistemics.EvidenceAssumed || first.Coverage != epistemics.CoverageComplete {
finding.State, finding.Reason = epistemics.FindingUnknown, "evidence is assumed, unknown or incomplete"
diff --git a/tests/research_runtime/search_connector_test.go b/tests/research_runtime/search_connector_test.go
new file mode 100644
index 0000000..08da9ad
--- /dev/null
+++ b/tests/research_runtime/search_connector_test.go
@@ -0,0 +1,41 @@
+package research_runtime_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ researchadapter "git.cin.su/m/Assistant/adapters/research"
+ "git.cin.su/m/Assistant/contracts/epistemics"
+)
+
+func TestSearchConnectorReturnsBoundedImmutableEvidence(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = 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"}
+ 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.
+ candidates, err := connector.Collect(context.Background(), request, 10)
+ if err != nil {
+ t.Fatalf("collect search evidence: %v", err)
+ }
+ if len(candidates) != 1 || candidates[0].Statement != "Verified title — Observed source excerpt" {
+ t.Fatalf("unexpected candidates: %+v", candidates)
+ }
+ if candidates[0].Evidence != epistemics.EvidenceObserved || candidates[0].Snapshot.ContentDigest == "" || candidates[0].Excerpt.Text == "" {
+ t.Fatalf("candidate lost evidence chain: %+v", candidates[0])
+ }
+}
+
+func TestSearchConnectorRejectsUnallowlistedEndpoint(t *testing.T) {
+ request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "q", SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, Query: "q"}
+ _, err := (researchadapter.SearchConnector{EndpointTemplate: "https://example.invalid/?q=%s"}).Collect(context.Background(), request, 10)
+ if err != researchadapter.ErrSearchRejected {
+ t.Fatalf("expected allowlist rejection, got %v", err)
+ }
+}