From 976edccfd8b4da36fbaa7f1486a5b58dfcfca2c6 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 24 Aug 2026 02:04:57 +0300 Subject: [PATCH] feat: route factual questions through bounded research --- adapters/research/search_connector.go | 114 ++++++++++++++++++ cmd/core-runtime/cognitive_interpretation.go | 7 -- .../cognitive_interpretation_test.go | 6 +- cmd/core-runtime/main.go | 39 ++++++ cmd/core-runtime/research_client.go | 25 ++++ cmd/creator-web/server.go | 4 +- cmd/research-runtime/main.go | 20 +++ contracts/epistemics/research.go | 11 ++ contracts/interaction/types.go | 1 + deploy/docker-compose.prod.yml | 2 + dev/ACTIVE_TASK.json | 4 +- ...-000275-semantic-web-research-routing.json | 23 ++++ docs/PROJECT_STATUS.md | 1 + ...ic-web-research-routing-2026-08-24-v1.json | 20 +++ modules/epistemics/domain/research.go | 2 +- .../research_runtime/search_connector_test.go | 41 +++++++ 16 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 adapters/research/search_connector.go create mode 100644 dev/tasks/DEV-000275-semantic-web-research-routing.json create mode 100644 docs/evidence/semantic-web-research-routing-2026-08-24-v1.json create mode 100644 tests/research_runtime/search_connector_test.go 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) + } +} -- 2.54.0