diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 66666f1..b7a9c6b 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -394,6 +394,11 @@ func main() { } needsCognition = false } + if isModelInventoryQuestion(semantic) { + candidates, inventoryErr := capabilityRepository.LoadCognitiveCandidates(r.Context(), capability.CapabilityID("creator-advisory"), session.PrincipalID) + planningReply = humanCognitiveModelInventory(candidates, inventoryErr) + needsCognition = false + } if isCognitiveChannelQuestion(utterance.Content) { health, healthErr := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID("cognitive-proposal")) planningReply = humanCognitiveChannelReply(utterance.Content, health, healthErr) @@ -1153,6 +1158,84 @@ func isCapabilityQuestion(message string) bool { return strings.Contains(lower, "что ты умеешь") || strings.Contains(lower, "что умеешь") || strings.Contains(lower, "какие возможности") } +func isModelInventoryQuestion(result interactiondomain.SemanticResult) bool { + return result.InformationRequestKind == interactiondomain.InformationRequestCognitiveModelInventory +} + +func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string { + if inventoryErr != nil { + return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат." + } + if len(candidates) == 0 { + return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся." + } + lines := make([]string, 0, len(candidates)+1) + lines = append(lines, "Подключённые модели из реестра:") + for _, candidate := range candidates { + modelName := string(candidate.Model.ProviderModelID) + if modelName == "" { + modelName = "имя модели не подтверждено" + } + status := cognitiveInventoryStatus(candidate) + pricing := cognitiveInventoryPricing(candidate.Provider) + details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)} + if pricing != "" { + details = append(details, pricing) + } + if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone { + details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode)) + } + lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")") + } + lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.") + return strings.Join(lines, "\n") +} + +func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string { + if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable { + if candidate.Binding.Activation == capability.ActivationActive { + return "активна" + } + return "доступна, не активирована для автоматического выбора" + } + if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded { + return "работает с ограничениями" + } + return "недоступна или не подтверждена" +} + +func cognitiveInventoryPricing(provider capability.ProviderInstance) string { + switch provider.BillingMode { + case capability.BillingFree: + return "бесплатная" + case capability.BillingPerInput: + return "платная: стоимость входа указана в реестре" + case capability.BillingPerOutput: + return "платная: стоимость выхода указана в реестре" + case capability.BillingPerRequest: + return "платная: стоимость запроса указана в реестре" + default: + return "стоимость не подтверждена" + } +} + +func humanAuthMode(mode capability.AuthMode) string { + switch mode { + case capability.AuthKey: + return "API-ключ" + case capability.AuthBearer: + return "Bearer-токен" + case capability.AuthUsernamePassword: + return "логин и пароль" + case capability.AuthOAuth: + return "OAuth" + case capability.AuthSecretFile: + return "защищённый файл секрета" + default: + return "защищённый способ" + } +} + func isCognitiveChannelQuestion(message string) bool { lower := strings.ToLower(message) markers := []string{"локальн", "модель", "когнитивн", "канал мышления", "проверь канал", "настройк"} diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index 6fd671e..0af9bea 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -11,6 +11,8 @@ import ( "time" "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/interaction" + interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" ) func TestBearerTokenRequiresBearerScheme(t *testing.T) { @@ -49,6 +51,37 @@ func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) { } } +func TestModelInventoryQuestionUsesReadOnlyFastPath(t *testing.T) { + for _, message := range []string{"какие модели доступны?", "напиши список настроек ИИ", "какие есть подключения ИИ"} { + result, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: message}, nil, nil, time.Now()) + if err != nil { + t.Fatalf("interpretation failed for %q: %v", message, err) + } + if !isModelInventoryQuestion(result) { + t.Fatalf("semantic inventory request %q was not recognized: kind=%q act=%q hints=%v", message, result.InformationRequestKind, result.Interpretation.SpeechAct, result.CapabilityHints) + } + } + ordinary, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: "настрой локальную модель"}, nil, nil, time.Now()) + if err != nil { + t.Fatal(err) + } + if isModelInventoryQuestion(ordinary) { + t.Fatal("configuration mutation must not use read-only model inventory path") + } +} + +func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) { + cost := int64(25) + text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{{ + Binding: capability.CognitiveBinding{DataPolicy: capability.DataPolicyExternalMessageOnly, Activation: capability.ActivationActive}, + Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable}, + Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable}, + }}, nil) + if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") { + t.Fatalf("unsafe or incomplete inventory: %q", text) + } +} + func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) { handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) }) request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`)) diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index d0968e5..3ad37ac 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000091-builtin-local-chat-routing.json", - "task_id": "DEV-000091", + "task_file": "dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json", + "task_id": "DEV-000092", "task_revision": 1 } diff --git a/dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json b/dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json new file mode 100644 index 0000000..106d6c2 --- /dev/null +++ b/dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json @@ -0,0 +1,26 @@ +{ + "task_id": "DEV-000092", + "task_revision": 1, + "architecture_revision": "DEV-000073-baseline", + "base_repo_revision": "2306c8d6b31fbf5ac0933ecc72b4f76bec7218f2", + "risk_tier": "E", + "intent": "Answer semantically identified model and external-AI inventory requests from the capability registry through a deterministic read-only chat fast path instead of treating them as unavailable cognition.", + "requirement_ids": ["Model inventory is readable from chat", "No secret material is shown", "Inventory does not require model reasoning"], + "allowed_change_scope": { + "modules": ["core-runtime", "interaction", "development-governance"], + "paths": ["cmd/core-runtime/main.go", "cmd/core-runtime/main_test.go", "modules/interaction/domain/chat.go", "modules/interaction/domain/chat_test.go", "modules/interaction/domain/semantics.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json"] + }, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**"]}, + "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": ["capability owner read-only LoadCognitiveCandidates port"], "may_change": ["human-facing chat fast-path text"]}, + "semantic_owner": "capability", + "canonical_state": [], + "state_transitions": ["model inventory question -> read-only capability registry query"], + "side_effects_and_privileges": ["No mutation", "CredentialRef and endpoint secret material are never rendered"], + "persistence_and_migrations": ["No persistence or migration"], + "failure_retry_restart": ["Registry read failure produces an honest unavailable-settings explanation", "No cognitive retry is required"], + "assumptions": ["CapabilityRepository.LoadCognitiveCandidates returns provider-neutral metadata and opaque secret references"], + "acceptance_scenarios": ["Какие модели доступны? returns the registered inventory", "Напиши список настроек ИИ returns safe settings", "Empty or unavailable registry does not expose credentials or internal identifiers"], + "required_tests": ["go test ./cmd/core-runtime", "go test ./...", "go vet ./...", "devctl preflight", "devctl check-diff", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index e0868ea..327d01c 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -194,6 +194,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo if hasQuestionMark { scores[interaction.SpeechActQuestion]++ } + if containsMeaningfulInterrogative(tokens) { + scores[interaction.SpeechActQuestion] += 2 + } if explicitCorrection { scores[interaction.SpeechActCorrection] += 6 } @@ -432,6 +435,9 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") { add("VERIFY_FACT") } + if containsStemInTokens(tokens, "модел", "ии") && containsStemInTokens(tokens, "настро", "список", "доступ", "подключ") { + add("VERIFY_FACT") + } values := make([]string, 0, len(seen)) for kind := range seen { values = append(values, kind) @@ -439,6 +445,18 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin return values } +func informationRequestKind(message string) InformationRequestKind { + tokens := semanticTokens(message) + if len(tokens) == 0 || containsStem(tokens[0], "настро") || !containsStemInTokens(tokens, "модел", "ии") { + return "" + } + inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро")) + if !inventoryCue { + return "" + } + return InformationRequestCognitiveModelInventory +} + func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction.SpeechAct]int) int64 { total := 0 for _, score := range scores { diff --git a/modules/interaction/domain/semantics.go b/modules/interaction/domain/semantics.go index 37cf4c7..249bed9 100644 --- a/modules/interaction/domain/semantics.go +++ b/modules/interaction/domain/semantics.go @@ -11,16 +11,21 @@ import ( ) type SemanticResult struct { - Interpretation interaction.UtteranceInterpretation - Candidates []interaction.SemanticCandidate - Projects []interaction.ProjectReference - Relations []interaction.InteractionRelation - ActiveFocus *situation.ActiveProjectFocus - Reply string - NeedsCognition bool - CapabilityHints []string + Interpretation interaction.UtteranceInterpretation + Candidates []interaction.SemanticCandidate + Projects []interaction.ProjectReference + Relations []interaction.InteractionRelation + ActiveFocus *situation.ActiveProjectFocus + Reply string + NeedsCognition bool + CapabilityHints []string + InformationRequestKind InformationRequestKind } +type InformationRequestKind string + +const InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY" + func InterpretUtterance(utterance interaction.HumanUtterance, projects []situation.ProjectDescriptor, previousFocus *situation.ActiveProjectFocus, now time.Time) (SemanticResult, error) { interpretationID, err := ids.New() if err != nil { @@ -46,9 +51,10 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati SelectionReason: selectedCandidate.SelectionReason, RecordedAtUnixMillis: millis, }, - Candidates: []interaction.SemanticCandidate{deterministicCandidate}, - NeedsCognition: selectedCandidate.NeedsCognition, - CapabilityHints: append([]string(nil), selectedCandidate.CapabilityHints...), + Candidates: []interaction.SemanticCandidate{deterministicCandidate}, + NeedsCognition: selectedCandidate.NeedsCognition, + CapabilityHints: append([]string(nil), selectedCandidate.CapabilityHints...), + InformationRequestKind: informationRequestKind(utterance.Content), } explicitFocus := explicitProjectFocus(utterance.Content, projects) projectRefs, err := projectReferences(utterance, projects, explicitFocus, previousFocus, millis)