From 3b4ce66df08e7979ca56fe04f2b7e8c1eab49f90 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 02:25:42 +0300 Subject: [PATCH] feat: answer model inventory from capability registry --- cmd/core-runtime/main.go | 89 +++++++++++++++++++ cmd/core-runtime/main_test.go | 20 +++++ dev/ACTIVE_TASK.json | 4 +- ...2-cognitive-model-inventory-fast-path.json | 26 ++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000092-cognitive-model-inventory-fast-path.json diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 66666f1..f2da7bf 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -394,6 +394,11 @@ func main() { } needsCognition = false } + if isModelInventoryQuestion(utterance.Content) { + 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,90 @@ func isCapabilityQuestion(message string) bool { return strings.Contains(lower, "что ты умеешь") || strings.Contains(lower, "что умеешь") || strings.Contains(lower, "какие возможности") } +func isModelInventoryQuestion(message string) bool { + lower := strings.ToLower(strings.TrimSpace(message)) + if lower == "" { + return false + } + asksAboutModels := strings.Contains(lower, "модел") || strings.Contains(lower, "ии") && (strings.Contains(lower, "внешн") || strings.Contains(lower, "настро") || strings.Contains(lower, "подключ")) + asksForInventory := strings.Contains(lower, "какие") || strings.Contains(lower, "какая") || strings.Contains(lower, "список") || strings.Contains(lower, "доступ") || strings.Contains(lower, "настро") + return asksAboutModels && asksForInventory +} + +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..b8e50b1 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -49,6 +49,26 @@ func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) { } } +func TestModelInventoryQuestionUsesReadOnlyFastPath(t *testing.T) { + for _, message := range []string{"какие модели доступны?", "напиши список настроек ИИ", "какие есть подключения ИИ"} { + if !isModelInventoryQuestion(message) { + t.Fatalf("message %q was not recognized as model inventory question", message) + } + } +} + +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..5d4a697 --- /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 model and external-AI settings questions 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", "development-governance"], + "paths": ["cmd/core-runtime/main.go", "cmd/core-runtime/main_test.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"] +}