diff --git a/contracts/configuration/types.go b/contracts/configuration/types.go index 0c71298..389341b 100644 --- a/contracts/configuration/types.go +++ b/contracts/configuration/types.go @@ -81,6 +81,9 @@ type BootstrapConfig struct { RecoveryLocator string } +// ConversationalConfigurationCommand is an intent, not an assignment. Only +// Configuration may turn a SET command into durable state. + func (d SettingDefinition) Validate() error { if d.ID == "" || d.OwnerNamespace == "" || d.StableKey == "" || !ids.Revision(d.Revision).Valid() || d.ValueType == "" || d.Lifecycle == "" || d.RecordedAtUnixMillis < 1 { return fmt.Errorf("invalid setting definition") diff --git a/contracts/interaction/types.go b/contracts/interaction/types.go index 73266db..948393f 100644 --- a/contracts/interaction/types.go +++ b/contracts/interaction/types.go @@ -25,6 +25,7 @@ type SemanticApplicability string type SemanticCandidateSource string type SemanticCandidateSelectionState string type InformationRequestKind string +type ConfigurationCommandKind string const ( ChannelWebChat Channel = "WEB_CHAT" @@ -75,6 +76,8 @@ const ( InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY" InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE" InformationRequestResearchFailureExplanation InformationRequestKind = "RESEARCH_FAILURE_EXPLANATION" + ConfigurationCommandSet ConfigurationCommandKind = "SET" + ConfigurationCommandQuery ConfigurationCommandKind = "QUERY" ) type Conversation struct { @@ -106,6 +109,18 @@ type OriginalHumanIntent struct { RecordedAtUnixMillis int64 Text string } + +// ConfigurationCommand is Interaction-owned intent. Configuration remains +// the only owner allowed to materialise a SettingAssignment. +type ConfigurationCommand struct { + Kind ConfigurationCommandKind + PrincipalID string + DefinitionRef string + TypedValue string + TemporaryOverride bool + SessionRef string + SourceRef string +} type InteractionRelation struct { ID InteractionRelationID FromUtteranceID HumanUtteranceID diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index ccbbfd5..d704c5e 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-022-wire-governed-self-evolution-loop-using-existing-development-gates.json","task_id":"GEN2-022","task_revision":1} \ No newline at end of file +{"task_file":"dev/tasks/GEN2-023-make-conversational-control-the-primary-configuration-and-operational-ux.json","task_id":"GEN2-023","task_revision":1} diff --git a/modules/configuration/domain/configuration.go b/modules/configuration/domain/configuration.go index f4869be..4d97c95 100644 --- a/modules/configuration/domain/configuration.go +++ b/modules/configuration/domain/configuration.go @@ -2,10 +2,40 @@ package domain import ( "fmt" - "sort" "git.cin.su/m/Assistant/contracts/configuration" + "sort" ) -func Resolve(definition configuration.SettingDefinition, assignments []configuration.SettingAssignment, selector configuration.ScopeSelector) (configuration.EffectiveSetting,error){if err:=definition.Validate();err!=nil{return configuration.EffectiveSetting{},err}; var matches []configuration.SettingAssignment;for _,a:=range assignments{if err:=a.Validate();err!=nil{return configuration.EffectiveSetting{},err};if a.DefinitionRef==string(definition.ID)&&a.Lifecycle==configuration.SettingActive&&a.Selector.PrincipalRef==selector.PrincipalRef&&a.Selector.SkillRef==selector.SkillRef{matches=append(matches,a)}};if len(matches)==0{return configuration.EffectiveSetting{DefinitionRef:string(definition.ID),Value:definition.DefaultValue,ResolutionTrace:[]string{"system-default"}},nil};sort.SliceStable(matches,func(i,j int)bool{return specificity(matches[i].Selector)>specificity(matches[j].Selector)});if len(matches)>1&&specificity(matches[0].Selector)==specificity(matches[1].Selector){return configuration.EffectiveSetting{},fmt.Errorf("configuration conflict")};return configuration.EffectiveSetting{DefinitionRef:string(definition.ID),Value:matches[0].TypedValue,WinningAssignmentRef:string(matches[0].ID),ResolutionTrace:[]string{"specificity-selected"}},nil} -func specificity(s configuration.ScopeSelector)int{return len(s.RequiredContextBindings)+boolInt(s.SkillRef!="")+boolInt(s.TaskRef!="")+boolInt(s.SessionRef!="")+boolInt(s.TemporaryOverride)} -func boolInt(v bool)int{if v{return 1};return 0} +var ErrInvalidConfigurationCommand = fmt.Errorf("invalid conversational configuration command") + +func Resolve(definition configuration.SettingDefinition, assignments []configuration.SettingAssignment, selector configuration.ScopeSelector) (configuration.EffectiveSetting, error) { + if err := definition.Validate(); err != nil { + return configuration.EffectiveSetting{}, err + } + var matches []configuration.SettingAssignment + for _, a := range assignments { + if err := a.Validate(); err != nil { + return configuration.EffectiveSetting{}, err + } + if a.DefinitionRef == string(definition.ID) && a.Lifecycle == configuration.SettingActive && a.Selector.PrincipalRef == selector.PrincipalRef && a.Selector.SkillRef == selector.SkillRef { + matches = append(matches, a) + } + } + if len(matches) == 0 { + return configuration.EffectiveSetting{DefinitionRef: string(definition.ID), Value: definition.DefaultValue, ResolutionTrace: []string{"system-default"}}, nil + } + sort.SliceStable(matches, func(i, j int) bool { return specificity(matches[i].Selector) > specificity(matches[j].Selector) }) + if len(matches) > 1 && specificity(matches[0].Selector) == specificity(matches[1].Selector) { + return configuration.EffectiveSetting{}, fmt.Errorf("configuration conflict") + } + return configuration.EffectiveSetting{DefinitionRef: string(definition.ID), Value: matches[0].TypedValue, WinningAssignmentRef: string(matches[0].ID), ResolutionTrace: []string{"specificity-selected"}}, nil +} +func specificity(s configuration.ScopeSelector) int { + return len(s.RequiredContextBindings) + boolInt(s.SkillRef != "") + boolInt(s.TaskRef != "") + boolInt(s.SessionRef != "") + boolInt(s.TemporaryOverride) +} +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/modules/configuration/domain/conversation.go b/modules/configuration/domain/conversation.go new file mode 100644 index 0000000..433e620 --- /dev/null +++ b/modules/configuration/domain/conversation.go @@ -0,0 +1,32 @@ +package domain + +import ( + "fmt" + "git.cin.su/m/Assistant/contracts/configuration" + "git.cin.su/m/Assistant/platform/ids" + "strings" + "time" +) + +var ErrConfigurationAuthorityRequired = fmt.Errorf("configuration change requires Authority and Commit") + +func AssignmentFromConversation(kind, principalID, definitionRef, typedValue, sessionRef, sourceRef string, temporaryOverride bool, definition configuration.SettingDefinition, now time.Time) (configuration.SettingAssignment, error) { + if kind != "SET" || principalID == "" || definitionRef != string(definition.ID) || strings.TrimSpace(typedValue) == "" || now.IsZero() || definition.ValueType == configuration.ValueSecretRef { + return configuration.SettingAssignment{}, ErrInvalidConfigurationCommand + } + if sourceRef == "" { + return configuration.SettingAssignment{}, ErrInvalidConfigurationCommand + } + id, err := ids.New() + if err != nil { + return configuration.SettingAssignment{}, err + } + return configuration.SettingAssignment{ID: configuration.SettingAssignmentID(id), PrincipalID: principalID, DefinitionRef: string(definition.ID), DefinitionRevision: definition.Revision, Revision: 1, Selector: configuration.ScopeSelector{PrincipalRef: principalID, SessionRef: sessionRef, TemporaryOverride: temporaryOverride}, TypedValue: strings.TrimSpace(typedValue), Source: configuration.SourceHumanDirective, SourceRef: sourceRef, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: now.UnixMilli()}, nil +} + +func ExplainEffectiveSetting(definition configuration.SettingDefinition, effective configuration.EffectiveSetting) string { + if effective.WinningAssignmentRef == "" { + return fmt.Sprintf("Настройка %s сейчас имеет системное значение %q.", definition.StableKey, effective.Value) + } + return fmt.Sprintf("Настройка %s сейчас имеет значение %q; применено scoped-правило %s.", definition.StableKey, effective.Value, effective.WinningAssignmentRef) +} diff --git a/modules/configuration/domain/conversation_test.go b/modules/configuration/domain/conversation_test.go new file mode 100644 index 0000000..4a7fb12 --- /dev/null +++ b/modules/configuration/domain/conversation_test.go @@ -0,0 +1,23 @@ +package domain_test + +import ( + "git.cin.su/m/Assistant/contracts/configuration" + "git.cin.su/m/Assistant/modules/configuration/domain" + "testing" + "time" +) + +func TestConversationCreatesScopedAssignmentWithoutBypassingAuthority(t *testing.T) { + d := configuration.SettingDefinition{ID: "setting:language", OwnerNamespace: "interaction", StableKey: "language", Revision: 2, ValueType: configuration.ValueString, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: 1} + a, err := domain.AssignmentFromConversation("SET", "principal-1", "setting:language", "ru", "session-1", "utterance-1", true, d, time.UnixMilli(2)) + if err != nil || a.Selector.PrincipalRef != "principal-1" || !a.Selector.TemporaryOverride || a.Source != configuration.SourceHumanDirective { + t.Fatalf("assignment=%+v err=%v", a, err) + } +} + +func TestSecretConfigurationCannotReceiveConversationalValue(t *testing.T) { + d := configuration.SettingDefinition{ID: "setting:key", OwnerNamespace: "capability", StableKey: "key", Revision: 1, ValueType: configuration.ValueSecretRef, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: 1} + if _, err := domain.AssignmentFromConversation("SET", "p", string(d.ID), "secret", "", "u", false, d, time.UnixMilli(2)); err == nil { + t.Fatal("secret value accepted") + } +} diff --git a/modules/interaction/domain/configuration_command.go b/modules/interaction/domain/configuration_command.go new file mode 100644 index 0000000..0154dc9 --- /dev/null +++ b/modules/interaction/domain/configuration_command.go @@ -0,0 +1,31 @@ +package domain + +import ( + "git.cin.su/m/Assistant/contracts/interaction" + "strings" +) + +// ParseConfigurationCommand recognises bounded human intent and never writes +// configuration or invokes Authority/Commit. +func ParseConfigurationCommand(message, principalID, sourceRef string) interaction.ConfigurationCommand { + text := strings.TrimSpace(strings.ToLower(message)) + if principalID == "" || sourceRef == "" { + return interaction.ConfigurationCommand{} + } + if strings.Contains(text, "какая настройка") || strings.HasPrefix(text, "какое значение") { + return interaction.ConfigurationCommand{Kind: interaction.ConfigurationCommandQuery, PrincipalID: principalID, SourceRef: sourceRef} + } + temporary := strings.Contains(text, "только сейчас") || strings.Contains(text, "на этот раз") || strings.Contains(text, "временно") + if strings.Contains(text, "настрой") || strings.Contains(text, "установи") || strings.Contains(text, "сделай") { + parts := strings.SplitN(message, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(parts[0]), "настройка")) + value := strings.TrimSpace(parts[1]) + for _, marker := range []string{"только сейчас", "на этот раз", "временно"} { + value = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(value), marker, "")) + } + return interaction.ConfigurationCommand{Kind: interaction.ConfigurationCommandSet, PrincipalID: principalID, DefinitionRef: key, TypedValue: value, TemporaryOverride: temporary, SourceRef: sourceRef} + } + } + return interaction.ConfigurationCommand{} +} diff --git a/modules/interaction/domain/configuration_command_test.go b/modules/interaction/domain/configuration_command_test.go new file mode 100644 index 0000000..38274f7 --- /dev/null +++ b/modules/interaction/domain/configuration_command_test.go @@ -0,0 +1,14 @@ +package domain_test + +import ( + "git.cin.su/m/Assistant/contracts/interaction" + interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" + "testing" +) + +func TestConversationParserProducesIntentOnly(t *testing.T) { + value := interactiondomain.ParseConfigurationCommand("Настройка language = ru только сейчас", "principal-1", "utterance-1") + if value.Kind != interaction.ConfigurationCommandSet || value.DefinitionRef != "language" || value.TypedValue != "ru" || !value.TemporaryOverride { + t.Fatalf("value=%+v", value) + } +}