Semantic-first Domovoy chat routing and reflex drift audit #270

Merged
m merged 2 commits from codex/chat-semantic-inventory into main 2026-08-24 15:46:57 +00:00
8 changed files with 147 additions and 9 deletions
+47 -4
View File
@@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"net/http"
"net/url"
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
@@ -61,12 +63,53 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
}
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
switch result.Interpretation.Applicability {
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
return true
default:
return shouldRequestSemanticInterpretation(result)
}
// shouldRequestSemanticInterpretation keeps the deterministic layer as a
// fast reflex, not as the meaning authority. Questions, requests, directives,
// mixed signals and capability-bearing messages go through cognitive
// interpretation; only stable low-risk conversational/assertive reflexes may
// answer without it.
func shouldRequestSemanticInterpretation(result interactiondomain.SemanticResult) bool {
if result.Interpretation.Clarification == interaction.ClarificationRequired {
return false
}
if result.NeedsCognition || result.InformationRequestKind != "" || len(result.CapabilityHints) > 0 {
return true
}
if result.Interpretation.Applicability != interaction.SemanticApplicable {
return true
}
switch result.Interpretation.SpeechAct {
case interaction.SpeechActConversational, interaction.SpeechActConfirmation, interaction.SpeechActRejection:
return false
case interaction.SpeechActAssertion:
return result.Interpretation.ConfidenceMillis < 850
default:
return true
}
}
const (
deterministicReflexAuditInterval = 10 * time.Minute
deterministicReflexAuditSample = uint32(16)
)
// shouldAuditDeterministicReflex samples stable reflexes in a time bucket.
// This is observability, not a correctness gate: a failed audit leaves the
// reflex usable, while a successful disagreement can replace it with the
// cognitive candidate and becomes durable evidence through the normal
// candidate store.
func shouldAuditDeterministicReflex(result interactiondomain.SemanticResult, utteranceID string, now time.Time) bool {
if shouldRequestSemanticInterpretation(result) || utteranceID == "" {
return false
}
bucket := now.Unix() / int64(deterministicReflexAuditInterval/time.Second)
hash := fnv.New32a()
_, _ = hash.Write([]byte(utteranceID))
_, _ = hash.Write([]byte(fmt.Sprintf(":%d", bucket)))
return hash.Sum32()%deterministicReflexAuditSample == 0
}
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
@@ -2,6 +2,7 @@ package main
import (
"testing"
"time"
"git.cin.su/m/Assistant/contracts/interaction"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
@@ -34,3 +35,42 @@ func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
t.Fatal("operational question must retain strict interpretation routing")
}
}
func TestDirectiveAndInventoryPreferMeaningInterpretationEvenWhenDeterministicLooksApplicable(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActDirective,
Applicability: interaction.SemanticApplicable,
ConfidenceMillis: 980,
},
InformationRequestKind: interaction.InformationRequestCognitiveModelInventory,
}
if !shouldRequestSemanticInterpretation(result) {
t.Fatal("model inventory directive must be interpreted by meaning, not only token reflex")
}
}
func TestStableReflexIsPeriodicallySampledForDrift(t *testing.T) {
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActConversational, Applicability: interaction.SemanticApplicable,
}}
found := false
for minute := 0; minute < 160; minute++ {
if shouldAuditDeterministicReflex(result, "utterance-drift-probe", time.Unix(int64(minute)*60, 0)) {
found = true
break
}
}
if !found {
t.Fatal("stable reflex was never sampled during the audit window")
}
}
func TestMeaningfulSignalsAreNotReflexOnly(t *testing.T) {
for _, act := range []interaction.SpeechAct{interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate, interaction.SpeechActDirective} {
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: act, Applicability: interaction.SemanticApplicable}}
if !shouldRequestSemanticInterpretation(result) {
t.Fatalf("%s must prefer cognitive interpretation", act)
}
}
}
+3 -1
View File
@@ -514,7 +514,9 @@ func main() {
}
cognitiveAttempted := false
cognitiveAvailable := true
if shouldRequestBroaderInterpretation(semantic) {
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
if semanticInterpretationRequested || semanticReflexAudit {
cognitiveAttempted = true
projectNames := make([]string, 0, len(semantic.Projects))
for _, ref := range semantic.Projects {
+3
View File
@@ -102,8 +102,11 @@ services:
LISTEN_ADDR: ":8083"
CORE_RUNTIME_URL: http://core-runtime:8080
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://docker.cin.su:18082/v1}
LOCAL_MEDIUM_PROVIDER_TIMEOUT: ${LOCAL_MEDIUM_PROVIDER_TIMEOUT:-120s}
LOCAL_LIFEBOAT_PROVIDER_URL: ${LOCAL_LIFEBOAT_PROVIDER_URL:-}
LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434
COGNITIVE_ALLOW_CANARY: "true"
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
ports: ["19083:8083"]
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000291-mobile-acceptance-evidence.json",
"task_id": "DEV-000291",
"task_file": "dev/tasks/DEV-000292-development-mode-runtime-access.json",
"task_id": "DEV-000292",
"task_revision": 1
}
@@ -0,0 +1,48 @@
{
"task_id": "DEV-000292",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "HEAD",
"risk_tier": "E",
"intent": "Provide an explicitly isolated development/test runtime profile for Domovoy so local model, speech, image, OCR, Docker and diagnostic paths are usable without changing production behavior.",
"requirement_ids": ["isolated development runtime", "local multimodal model access", "development diagnostics"],
"allowed_change_scope": {
"modules": ["interaction", "core-runtime", "cognitive-worker", "creator-web", "deployment", "development-governance"],
"paths": [
"cmd/cognitive-worker/**",
"cmd/core-runtime/**",
"adapters/models/**",
"contracts/cognitive/**",
"modules/interaction/domain/**",
"deploy/docker-compose.dev.yml",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000292-development-mode-runtime-access.json",
"docs/evidence/**",
"docs/PROJECT_STATUS.md"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "security/**", "database/**", "deploy/docker-compose.prod.yml", "LLM-local/**"]
},
"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 cognitive gateway", "existing local model HTTP APIs", "existing dev compose network"],
"may_change": ["dev-only modality and diagnostic wiring; no production contract change"]
},
"semantic_owner": "development-governance owns the isolated dev profile; cognitive-worker owns model capability routing",
"canonical_state": ["existing dev databases and Docker volumes only"],
"state_transitions": ["production-only restriction -> explicit dev-only capability switch"],
"side_effects_and_privileges": ["dev network and Docker diagnostics only", "no production credentials", "no secret logging", "no production external mutation"],
"persistence_and_migrations": ["dev volumes only; no production migration"],
"failure_retry_restart": ["dev services remain independently restartable", "unsupported modality stays explicitly unavailable"],
"assumptions": ["the developer starts the profile separately from production", "test credentials are disposable and never reused in production"],
"acceptance_scenarios": ["dev profile starts independently", "text, speech, image and OCR routes are discoverable or report a precise unavailable state", "diagnostic endpoints are reachable inside the dev network", "meaningful chat requests prefer cognitive interpretation over token reflexes", "stable reflexes are sampled periodically for cognitive drift and a disagreement is observable as a cognitive candidate", "production compose remains unchanged", "no secret value appears in source or logs"],
"required_tests": ["go test ./...", "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", "docker compose -f deploy/docker-compose.dev.yml config", "git diff --check", "dev runtime health smoke"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
+2 -2
View File
@@ -215,7 +215,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "подум", "понят", "посмотри"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
decisionStems := []string{"решил", "решение", "выбираю"}
@@ -471,7 +471,7 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
inquiry := candidate.PrimarySpeechAct == interaction.SpeechActQuestion || candidate.PrimarySpeechAct == interaction.SpeechActRequestToInvestigate || candidate.SecondarySpeechAct == interaction.SpeechActQuestion || candidate.SecondarySpeechAct == interaction.SpeechActRequestToInvestigate
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл")
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
if cognitiveSubject && inventoryRequest && inventoryCue {
return interaction.InformationRequestCognitiveModelInventory
}
+2
View File
@@ -121,6 +121,8 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
{"сообщите список подключённых моделей", interaction.InformationRequestCognitiveModelInventory},
}
for _, test := range cases {
result, err := InterpretUtterance(interaction.HumanUtterance{Content: test.message}, nil, nil, time.Unix(10, 0))