diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml new file mode 100644 index 0000000..1812323 --- /dev/null +++ b/.gitea/workflows/production-deploy.yml @@ -0,0 +1,20 @@ +name: Deploy production stack + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Trigger Portainer Git deployment + env: + PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }} + run: | + set -eu + test -n "$PORTAINER_WEBHOOK_URL" + curl --fail --silent --show-error --retry 3 --retry-delay 5 \ + --request POST "$PORTAINER_WEBHOOK_URL" diff --git a/adapters/postgres/capability_repository.go b/adapters/postgres/capability_repository.go index 4cd0096..1d62375 100644 --- a/adapters/postgres/capability_repository.go +++ b/adapters/postgres/capability_repository.go @@ -322,18 +322,18 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error { _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances - (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind, - value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis) + (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind, + value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.BillingMode, value.AuthMode, value.InputCostMicros, value.OutputCostMicros, value.RequestCostMicros) return err } func (r *CapabilityRepository) SaveModelDescriptor(ctx context.Context, value capability.ModelDescriptor) error { _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors - (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.Revision, + (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.ProviderInstanceID, value.ProviderModelID, value.Fingerprint, value.MaxContextBytes, value.Modalities, - value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis) + value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.IntelligenceScoreMillis) return err } @@ -349,8 +349,8 @@ func (r *CapabilityRepository) SaveCognitiveBinding(ctx context.Context, value c func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) { rows, err := r.pool.Query(ctx, `SELECT b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref, - p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)), - m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0)) + p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),p.billing_mode,p.auth_mode,p.input_cost_micros,p.output_cost_micros,p.request_cost_micros, + m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0)),m.intelligence_score_millis FROM capability.cognitive_binding_revision_heads bh JOIN capability.cognitive_bindings b ON b.binding_id=bh.binding_id AND b.revision=bh.revision JOIN capability.cognitive_provider_instances p ON p.provider_instance_id=b.provider_instance_id AND p.revision=b.provider_revision @@ -366,8 +366,8 @@ func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capa for rows.Next() { var value capability.ResolvedCognitiveBinding err = rows.Scan(&value.Binding.ID, &value.Binding.Revision, &value.Binding.CapabilityID, &value.Binding.OwnerPrincipalID, &value.Binding.ProviderInstanceID, &value.Binding.ProviderRevision, &value.Binding.ModelDescriptorID, &value.Binding.ModelRevision, &value.Binding.DataPolicy, &value.Binding.Qualification, &value.Binding.Activation, &value.Binding.Health, &value.Binding.LimitPolicyRef, - &value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis, - &value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis) + &value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis, &value.Provider.BillingMode, &value.Provider.AuthMode, &value.Provider.InputCostMicros, &value.Provider.OutputCostMicros, &value.Provider.RequestCostMicros, + &value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis, &value.Model.IntelligenceScoreMillis) if err != nil { return nil, err } @@ -414,7 +414,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value return err } defer func() { _ = tx.Rollback(ctx) }() - _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis) + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (provider_instance_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,billing_mode=EXCLUDED.billing_mode,auth_mode=EXCLUDED.auth_mode,input_cost_micros=EXCLUDED.input_cost_micros,output_cost_micros=EXCLUDED.output_cost_micros,request_cost_micros=EXCLUDED.request_cost_micros WHERE capability.cognitive_provider_instances.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis, value.Provider.BillingMode, value.Provider.AuthMode, value.Provider.InputCostMicros, value.Provider.OutputCostMicros, value.Provider.RequestCostMicros) if err != nil { return err } @@ -423,7 +423,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value return err } for index, model := range value.Models { - _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis) + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (model_descriptor_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,intelligence_score_millis=EXCLUDED.intelligence_score_millis WHERE capability.cognitive_model_descriptors.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis, model.IntelligenceScoreMillis) if err != nil { return err } @@ -433,7 +433,11 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value return err } binding := value.Bindings[index] - _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef) + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (binding_id,revision) DO UPDATE SET health=EXCLUDED.health WHERE capability.cognitive_bindings.health <> EXCLUDED.health`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision) if err != nil { return err } @@ -453,10 +457,10 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context, } defer func() { _ = tx.Rollback(ctx) }() p, m, b, e := value.Provider, value.Model, value.Binding, value.Evaluation - if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis, p.BillingMode, p.AuthMode, p.InputCostMicros, p.OutputCostMicros, p.RequestCostMicros); err != nil { return err } - if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis, m.IntelligenceScoreMillis); err != nil { return err } if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil { @@ -475,6 +479,32 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context, return tx.Commit(ctx) } +func (r *CapabilityRepository) SaveCognitiveActivationPromotion(ctx context.Context, value capability.CognitiveActivationPromotion) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + b, e := value.Binding, value.Evaluation + if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil { + return err + } + if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_evaluated_capabilities (evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, e.ID, e.BindingID, e.BindingRevision, e.CapabilityID, e.Result, e.QualityScoreMillis, e.CriticalFailureMillis, e.BenchmarkVersion, e.ArtifactID, e.ObservedAtUnixMillis); err != nil { + return err + } + if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_bindings SET activation='DEPRECATED' WHERE capability_id=$1 AND owner_principal_id=$2 AND activation='ACTIVE' AND NOT (binding_id=$3 AND revision=$4)`, b.CapabilityID, b.OwnerPrincipalID, b.ID, b.Revision); err != nil { + return err + } + result, err := tx.Exec(ctx, `UPDATE capability.cognitive_binding_revision_heads SET revision=$1,updated_at=CURRENT_TIMESTAMP WHERE binding_id=$2 AND revision=$3`, b.Revision, b.ID, value.ExpectedBindingRevision) + if err != nil { + return err + } + if result.RowsAffected() != 1 { + return ErrCapabilityBindingConflict + } + return tx.Commit(ctx) +} + func (r *CapabilityRepository) SavePerformanceEvidence(ctx context.Context, value capability.PerformanceEvidence) error { _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_performance_evidence (evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.ModelDescriptorID, value.ModelRevision, value.HardwareProfile, value.Runtime, value.Quantization, value.ContextUnits, value.ThinkingEnabled, value.GenerationTPSMillis, value.PromptTPSMillis, value.TTFTMicros, value.LatencyMicros, value.MemoryBytes, value.ReliabilityMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis) return err diff --git a/cmd/cognitive-worker/discovery.go b/cmd/cognitive-worker/discovery.go index b34e560..68c4a83 100644 --- a/cmd/cognitive-worker/discovery.go +++ b/cmd/cognitive-worker/discovery.go @@ -23,7 +23,7 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) { return } discovered, probe := registration.Discovery.Discover(r.Context()) - snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()} + snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, AuthMode: registration.AuthMode, BillingMode: registration.BillingMode, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()} for _, model := range discovered { snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved}) } diff --git a/cmd/cognitive-worker/gateway.go b/cmd/cognitive-worker/gateway.go index 6504b5b..d7b3a31 100644 --- a/cmd/cognitive-worker/gateway.go +++ b/cmd/cognitive-worker/gateway.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" "git.cin.su/m/Assistant/adapters/models" @@ -29,6 +30,8 @@ type providerRegistration struct { Kind capability.ProviderKind EndpointRef capability.EndpointBindingRef CredentialRef capability.CredentialRef + AuthMode capability.AuthMode + BillingMode capability.BillingMode Provider advisoryProvider Discovery models.LocalProvider } @@ -71,10 +74,10 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request if g.allowCanary && g.canaryMaxOutputBytes > 0 && maxOutputBytes > g.canaryMaxOutputBytes { maxOutputBytes = g.canaryMaxOutputBytes } - requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes)} + requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes), Objective: selectionObjectiveForMeaning(request.Meaning, request.Message)} selected, err := g.resolver.Resolve(r.Context(), requirement) if err != nil { - writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable}) + writeGatewayResponse(w, builtinAdvisory(request)) return } if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged { @@ -83,7 +86,7 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request } provider := g.provider(selected) if provider == nil { - writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable}) + writeGatewayResponse(w, builtinAdvisory(request)) return } invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID} @@ -110,9 +113,57 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request response = fallbackResponse } } + if response.FinishReason != cognitive.FinishCompleted && fallbackEligible(response.FinishReason) { + writeGatewayResponse(w, builtinAdvisory(request)) + return + } writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}) } +func selectionObjectiveForMessage(message string) capability.SelectionObjective { + lower := strings.ToLower(message) + if strings.Contains(lower, "дешев") || strings.Contains(lower, "бесплат") || strings.Contains(lower, "эконом") || strings.Contains(lower, "cost") || strings.Contains(lower, "cheap") { + return capability.SelectionEconomy + } + if strings.Contains(lower, "быстр") || strings.Contains(lower, "срочно") || strings.Contains(lower, "статус") || strings.Contains(lower, "quick") || strings.Contains(lower, "fast") { + return capability.SelectionSpeed + } + if strings.Contains(lower, "сложн") || strings.Contains(lower, "анализ") || strings.Contains(lower, "код") || strings.Contains(lower, "план") || strings.Contains(lower, "глубок") || strings.Contains(lower, "reason") || strings.Contains(lower, "code") { + return capability.SelectionQuality + } + return capability.SelectionBalanced +} + +func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string) capability.SelectionObjective { + if meaning.CapabilityKind != "" || len(meaning.CapabilityHints) > 0 || meaning.SpeechAct != "" || meaning.RequestedOutcome != "" { + kinds := append([]capability.CapabilityKind{meaning.CapabilityKind}, meaning.CapabilityHints...) + for _, kind := range kinds { + switch kind { + case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindVerifyFact, capability.CapabilityKindInspectProject, capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs: + return capability.SelectionQuality + case capability.CapabilityKindScheduleReminder: + return capability.SelectionSpeed + } + } + if meaning.RequestedOutcome != "" || meaning.SpeechAct == "REQUEST_TO_INVESTIGATE" || meaning.SpeechAct == "DIRECTIVE" { + return capability.SelectionQuality + } + if meaning.ConfidenceMillis > 0 { + return capability.SelectionBalanced + } + } + return selectionObjectiveForMessage(message) +} + +func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse { + message := strings.ToLower(strings.TrimSpace(request.Message)) + text := "Я рядом и продолжаю работу во встроенном режиме Домового. Подключённые модели сейчас недоступны; могу помочь проверить канал, сравнить доступные модели и подготовить безопасное переключение." + if strings.Contains(message, "модел") || strings.Contains(message, "канал") || strings.Contains(message, "ии") || strings.Contains(message, "ai") { + text = "Встроенный режим Домового доступен. Я могу проверить подключённые модели по назначению, качеству, скорости и стоимости, после чего предложить подходящую; ключи и пароли в разговор не нужны." + } + return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted} +} + func (g cognitiveGateway) observe(ctx context.Context, selected capability.ResolvedCognitiveBinding, response cognitive.AdvisoryResponse, started time.Time) { usage := response.Usage observationID, idErr := ids.New() diff --git a/cmd/cognitive-worker/gateway_registry_test.go b/cmd/cognitive-worker/gateway_registry_test.go index e2aeb74..891746d 100644 --- a/cmd/cognitive-worker/gateway_registry_test.go +++ b/cmd/cognitive-worker/gateway_registry_test.go @@ -114,7 +114,45 @@ func TestCanaryOutputLimitIsClamped(t *testing.T) { } } -func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) { +func TestSelectionObjectiveForMessage(t *testing.T) { + cases := []struct { + message string + objective capability.SelectionObjective + }{ + {"Сделай глубокий анализ кода", capability.SelectionQuality}, + {"Нужен быстрый статус", capability.SelectionSpeed}, + {"Выбери бесплатную модель", capability.SelectionEconomy}, + {"Помоги сформулировать ответ", capability.SelectionBalanced}, + } + for _, test := range cases { + if actual := selectionObjectiveForMessage(test.message); actual != test.objective { + t.Errorf("message=%q objective=%q, want %q", test.message, actual, test.objective) + } + } +} + +func TestSelectionObjectiveUsesTrustedMeaningBeforeText(t *testing.T) { + meaning := cognitive.TaskMeaning{CapabilityKind: capability.CapabilityKindModifyCode, SpeechAct: "DIRECTIVE"} + if actual := selectionObjectiveForMeaning(meaning, "сделай быстро и дёшево"); actual != capability.SelectionQuality { + t.Fatalf("objective=%q, want quality", actual) + } + if actual := selectionObjectiveForMeaning(cognitive.TaskMeaning{}, "сделай быстро"); actual != capability.SelectionSpeed { + t.Fatalf("fallback objective=%q, want speed", actual) + } +} + +func TestSelectionObjectiveForInterpretationUsesSemanticSignals(t *testing.T) { + request := cognitive.GatewayInterpretationRequest{RequestedOutcome: "сравнить варианты", DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "QUESTION"} + if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionQuality { + t.Fatalf("objective=%q, want quality", actual) + } + request = cognitive.GatewayInterpretationRequest{DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "ASSERTION"} + if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionSpeed { + t.Fatalf("objective=%q, want speed", actual) + } +} + +func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) { registry := ®istryStub{err: errors.New("no active binding")} provider := &stubAdvisoryProvider{} gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"unused": {{Provider: provider}}}, now: time.Now} @@ -125,7 +163,7 @@ func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) { if err := json.NewDecoder(response.Body).Decode(&result); err != nil { t.Fatal(err) } - if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishProviderUnavailable || provider.called { + if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishCompleted || result.AdvisoryText == "" || provider.called { t.Fatalf("status=%d result=%+v provider_called=%t", response.Code, result, provider.called) } } diff --git a/cmd/cognitive-worker/interpretation.go b/cmd/cognitive-worker/interpretation.go index 4c01924..379b85b 100644 --- a/cmd/cognitive-worker/interpretation.go +++ b/cmd/cognitive-worker/interpretation.go @@ -32,6 +32,7 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes), + Objective: selectionObjectiveForInterpretation(request), } selected, err := g.resolver.Resolve(r.Context(), requirement) if err != nil { @@ -76,6 +77,16 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage}) } +func selectionObjectiveForInterpretation(request cognitive.GatewayInterpretationRequest) capability.SelectionObjective { + if request.RequestedOutcome != "" || request.DeterministicApplicability == cognitive.InterpretationNeedsBroader || request.DeterministicApplicability == cognitive.InterpretationMixedIntent || request.DeterministicApplicability == cognitive.InterpretationLowConfidence { + return capability.SelectionQuality + } + if request.DeterministicConfidence >= 800 && request.DeterministicSpeechAct != "QUESTION" { + return capability.SelectionSpeed + } + return capability.SelectionBalanced +} + func interpretationPrompt(request cognitive.GatewayInterpretationRequest) string { projects := "none" if len(request.ProjectNames) > 0 { diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 6dea53c..cf8f1dd 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -44,19 +44,25 @@ func main() { if err != nil { log.Fatal(err) } - cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", Provider: cpuAdapter, Discovery: cpuAdapter} + cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, Provider: cpuAdapter, Discovery: cpuAdapter} providers[cpu.Kind] = append(providers[cpu.Kind], cpu) discoveries["cpu"] = cpu + if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil { + log.Fatal(lifeboatErr) + } else if enabled { + providers[lifeboat.Kind] = append(providers[lifeboat.Kind], lifeboat) + discoveries["lifeboat"] = lifeboat + } gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil) if err != nil { log.Fatal(err) } - gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", Provider: gpuAdapter, Discovery: gpuAdapter} + gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, Provider: gpuAdapter, Discovery: gpuAdapter} providers[gpu.Kind] = append(providers[gpu.Kind], gpu) discoveries["gpu"] = gpu if openAIProvider != nil { kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1")) - providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), Provider: openAIProvider}) + providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, Provider: openAIProvider}) } gateway := cognitiveGateway{resolver: resolver, providers: providers, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096} mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1}) @@ -66,12 +72,57 @@ func main() { mux.HandleFunc("POST /v1/interpretations", gateway.interpretationHandler) discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now} mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler) + refreshProviderDiscoveries(resolver, discovery) log.Printf("cognitive-worker listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) } } +func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) { + for name := range service.providers { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + registration, ok := service.providers[name] + if !ok || registration.Discovery == nil { + cancel() + continue + } + discovered, probe := registration.Discovery.Discover(ctx) + cancel() + snapshot := capability.CognitiveDiscoverySnapshot{ + ProviderInstanceID: registration.ProviderInstanceID, + ProviderKind: registration.Kind, + EndpointRef: registration.EndpointRef, + CredentialRef: registration.CredentialRef, + AuthMode: registration.AuthMode, + BillingMode: registration.BillingMode, + Enabled: true, + OwnerPrincipalID: service.owner, + CapabilityID: creatorAdvisoryCapabilityID, + DataPolicy: capability.DataPolicyLocalOnly, + LimitPolicyRef: "creator-advisory-local-v1", + Health: probe.Health, + FailureClass: capability.CognitiveFailureType(probe.Failure), + ObservedAtUnixMillis: time.Now().UnixMilli(), + } + for _, model := range discovered { + snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ + ProviderModelID: model.ProviderModelID, + Fingerprint: model.Fingerprint, + MaxContextBytes: model.MaxContextBytes, + Modalities: model.Modalities, + Capabilities: model.Capabilities, + Installed: capability.OperationalYes, + Loaded: capability.OperationalNotObserved, + Invokable: capability.OperationalNotObserved, + }) + } + if _, err := resolver.RegisterDiscovery(context.Background(), snapshot); err != nil { + log.Printf("startup cognitive discovery refresh failed for %s: %v", name, err) + } + } +} + func deterministicAdvisory(w http.ResponseWriter, r *http.Request) { request, ok := decodeAdvisoryRequest(w, r) if !ok { @@ -174,3 +225,22 @@ func envOr(name, fallback string) string { } return fallback } + +func newLifeboatProvider(rawURL string) (providerRegistration, bool, error) { + if rawURL == "" { + return providerRegistration{}, false, nil + } + adapter, err := models.NewOpenAICompatibleLocalAdapter(rawURL, nil) + if err != nil { + return providerRegistration{}, false, err + } + return providerRegistration{ + ProviderInstanceID: "local-lifeboat", + Kind: "openai-compatible-chat-v1", + EndpointRef: "local-lifeboat-v1", + AuthMode: capability.AuthNone, + BillingMode: capability.BillingFree, + Provider: adapter, + Discovery: adapter, + }, true, nil +} diff --git a/cmd/cognitive-worker/main_test.go b/cmd/cognitive-worker/main_test.go new file mode 100644 index 0000000..950f30c --- /dev/null +++ b/cmd/cognitive-worker/main_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) { + if _, enabled, err := newLifeboatProvider(""); err != nil || enabled { + t.Fatalf("empty lifeboat URL should disable provider: enabled=%v err=%v", enabled, err) + } + provider, enabled, err := newLifeboatProvider("http://lifeboat:8080/v1") + if err != nil || !enabled { + t.Fatalf("configured lifeboat should be enabled: enabled=%v err=%v", enabled, err) + } + if provider.ProviderInstanceID != "local-lifeboat" || provider.EndpointRef != "local-lifeboat-v1" || provider.Provider == nil { + t.Fatalf("unexpected lifeboat registration: %+v", provider) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index eff7fc6..66666f1 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -293,7 +293,10 @@ func main() { http.Error(w, "interpret chat message", http.StatusServiceUnavailable) return } + cognitiveAttempted := false + cognitiveAvailable := true if shouldRequestBroaderInterpretation(semantic) { + cognitiveAttempted = true projectNames := make([]string, 0, len(semantic.Projects)) for _, ref := range semantic.Projects { if ref.ProjectDisplayName != "" { @@ -303,7 +306,11 @@ func main() { if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted { if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil { semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus) + } else { + cognitiveAvailable = false } + } else { + cognitiveAvailable = false } } if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil { @@ -396,6 +403,10 @@ func main() { planningReply = helpPath.Reply needsCognition = false } + if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable { + planningReply = humanCognitiveDegradedReply(utterance.Content) + needsCognition = false + } var capabilityStatus capability.CapabilityResolutionStatus var capabilityKind capability.CapabilityKind var capabilityGapID capability.CapabilityGapID @@ -473,24 +484,27 @@ func main() { } } response := interaction.ChatMessageResponse{ - ConversationID: conversation.ID, - UtteranceID: utterance.ID, - OriginalIntentID: intent.ID, - SpeechAct: semantic.Interpretation.SpeechAct, - InterpretationState: utterance.InterpretationState, - NeedsCognition: needsCognition, - FallbackText: semantic.Reply, - ActiveProjectName: activeFocusName, - ProjectProvenance: activeFocusProvenance, - Clarification: semantic.Interpretation.Clarification, - WorkPlanned: workPlanned, - PlanningReply: planningReply, - CapabilityStatus: capabilityStatus, - CapabilityKind: capabilityKind, - CapabilityGapID: capabilityGapID, - DelegationID: string(delegationID), - DelegationState: string(delegationState), - Reminder: reminder, + ConversationID: conversation.ID, + UtteranceID: utterance.ID, + OriginalIntentID: intent.ID, + SpeechAct: semantic.Interpretation.SpeechAct, + InterpretationState: utterance.InterpretationState, + NeedsCognition: needsCognition, + FallbackText: semantic.Reply, + ActiveProjectName: activeFocusName, + ProjectProvenance: activeFocusProvenance, + Clarification: semantic.Interpretation.Clarification, + WorkPlanned: workPlanned, + PlanningReply: planningReply, + CapabilityStatus: capabilityStatus, + CapabilityKind: capabilityKind, + CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), + RequestedOutcome: semantic.Interpretation.RequestedOutcome, + InterpretationConfidenceMillis: semantic.Interpretation.ConfidenceMillis, + CapabilityGapID: capabilityGapID, + DelegationID: string(delegationID), + DelegationState: string(delegationState), + Reminder: reminder, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -816,6 +830,7 @@ func main() { return } if err := capabilityRepository.SaveCognitiveDiscovery(r.Context(), registration); err != nil { + log.Printf("cognitive discovery persistence failed: %v", err) http.Error(w, "save cognitive discovery", http.StatusInternalServerError) return } @@ -876,6 +891,45 @@ func main() { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(promotion) }) + mux.HandleFunc("POST /v1/creator/cognitive-activate", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + var request capability.CognitiveActivationPromotionRequest + decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid cognitive activation", http.StatusBadRequest) + return + } + request.RequestedByPrincipalID = session.PrincipalID + request.CapabilityID = "creator-advisory" + candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + var current capability.ResolvedCognitiveBinding + for _, candidate := range candidates { + if candidate.Binding.ID == request.BindingID && candidate.Binding.Revision == request.ExpectedBindingRevision { + current = candidate + break + } + } + promotion, err := capabilitydomain.BuildActivationPromotion(request, current) + if err != nil { + http.Error(w, "cognitive activation precondition failed", http.StatusConflict) + return + } + if err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion); err != nil { + http.Error(w, "cognitive activation conflict", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(promotion) + }) mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) { var value capability.PerformanceEvidence decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) @@ -1114,9 +1168,9 @@ func humanCognitiveChannelReply(message string, health capability.Health, health changeRequested := strings.Contains(strings.ToLower(message), "переключ") || strings.Contains(strings.ToLower(message), "настрой") || strings.Contains(strings.ToLower(message), "измени") || strings.Contains(strings.ToLower(message), "замени") if healthErr != nil { if changeRequested { - return "Я не вижу подтверждённого состояния локального канала. Изменение модели не выполнено. Откройте консоль, проверьте настройку и запустите проверку ещё раз." + return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку." } - return "Я не вижу подтверждённого состояния локального канала. Проверьте его в консоли или повторите вопрос позже." + return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз." } var observation string switch health.State { @@ -1130,11 +1184,24 @@ func humanCognitiveChannelReply(message string, health capability.Health, health observation = "Состояние основного локального канала пока не подтверждено." } if changeRequested { - return observation + " Я не меняю модель прямо из разговора: изменение выполняется в консоли с подтверждением. После изменения я увижу новую проверку канала автоматически." + if health.State == capability.HealthUnavailable { + return observation + " Но Домовой жив. Опишите новую локальную модель или канал — я помогу настроить аварийный режим и перед применением покажу итоговую конфигурацию." + } + return observation + " Я не меняю модель прямо из разговора: в обычном режиме изменение выполняется в консоли с подтверждением. После изменения я увижу новую проверку канала автоматически." + } + if health.State == capability.HealthUnavailable { + return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал." } return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели." } +func humanCognitiveDegradedReply(message string) string { + if strings.TrimSpace(message) == "" { + return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме." + } + return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления." +} + func humanCapabilityList(kinds []capability.CapabilityKind) string { labels := make([]string, 0, len(kinds)) for _, kind := range kinds { diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index e1a8a52..6fd671e 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -37,11 +37,18 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) { func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) { reply := humanCognitiveChannelReply("Переключи локальную модель", capability.Health{State: capability.HealthUnavailable}, nil) - if !strings.Contains(reply, "не меняю модель") || !strings.Contains(reply, "консоли") { + if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") { t.Fatalf("unexpected configuration reply: %q", reply) } } +func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) { + reply := humanCognitiveDegradedReply("Помоги настроить локальную модель") + if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") { + t.Fatalf("unexpected degraded reply: %q", reply) + } +} + 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/cmd/creator-web/server.go b/cmd/creator-web/server.go index 1a6ca2c..e064bce 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -159,20 +159,23 @@ type browserChatRequest struct { } type browserChatResponse struct { - ConversationID string `json:"conversation_id"` - UtteranceID string `json:"utterance_id"` - SpeechAct string `json:"speech_act"` - ReplyText string `json:"reply_text"` - Cognition string `json:"cognition"` - ActiveFocus string `json:"active_focus"` - ProjectProvenance string `json:"project_provenance"` - Clarification string `json:"clarification"` - WorkPlanned bool `json:"work_planned"` - CapabilityStatus string `json:"capability_status"` - CapabilityKind string `json:"capability_kind"` - DelegationID string `json:"delegation_id"` - DelegationState string `json:"delegation_state"` - ReminderScheduled bool `json:"reminder_scheduled"` + ConversationID string `json:"conversation_id"` + UtteranceID string `json:"utterance_id"` + SpeechAct string `json:"speech_act"` + ReplyText string `json:"reply_text"` + Cognition string `json:"cognition"` + ActiveFocus string `json:"active_focus"` + ProjectProvenance string `json:"project_provenance"` + Clarification string `json:"clarification"` + WorkPlanned bool `json:"work_planned"` + CapabilityStatus string `json:"capability_status"` + CapabilityKind string `json:"capability_kind"` + CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"` + RequestedOutcome string `json:"requested_outcome,omitempty"` + InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"` + DelegationID string `json:"delegation_id"` + DelegationState string `json:"delegation_state"` + ReminderScheduled bool `json:"reminder_scheduled"` } func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { @@ -355,7 +358,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { cognition = "unavailable" requestID, idErr := ids.New() if idErr == nil { - trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false} + trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false, Meaning: cognitive.TaskMeaning{SpeechAct: string(saved.SpeechAct), CapabilityKind: saved.CapabilityKind, CapabilityHints: saved.CapabilityHints, RequestedOutcome: saved.RequestedOutcome, ConfidenceMillis: saved.InterpretationConfidenceMillis}} payload, err = json.Marshal(trusted) if err == nil { var advisory cognitive.GatewayAdvisoryResponse @@ -372,20 +375,23 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(browserChatResponse{ - ConversationID: string(saved.ConversationID), - UtteranceID: string(saved.UtteranceID), - SpeechAct: string(saved.SpeechAct), - ReplyText: reply, - Cognition: cognition, - ActiveFocus: saved.ActiveProjectName, - ProjectProvenance: string(saved.ProjectProvenance), - Clarification: string(saved.Clarification), - WorkPlanned: saved.WorkPlanned, - CapabilityStatus: string(saved.CapabilityStatus), - CapabilityKind: string(saved.CapabilityKind), - DelegationID: saved.DelegationID, - DelegationState: saved.DelegationState, - ReminderScheduled: saved.Reminder != nil, + ConversationID: string(saved.ConversationID), + UtteranceID: string(saved.UtteranceID), + SpeechAct: string(saved.SpeechAct), + ReplyText: reply, + Cognition: cognition, + ActiveFocus: saved.ActiveProjectName, + ProjectProvenance: string(saved.ProjectProvenance), + Clarification: string(saved.Clarification), + WorkPlanned: saved.WorkPlanned, + CapabilityStatus: string(saved.CapabilityStatus), + CapabilityKind: string(saved.CapabilityKind), + CapabilityHints: saved.CapabilityHints, + RequestedOutcome: saved.RequestedOutcome, + InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis, + DelegationID: saved.DelegationID, + DelegationState: saved.DelegationState, + ReminderScheduled: saved.Reminder != nil, }) } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 9c7e694..79f71df 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -43,7 +43,7 @@ function renderCognitiveStatus(state) { const labels = { available: "канал мышления доступен", degraded: "канал мышления работает с ограничениями", - unavailable: "основной канал мышления недоступен", + unavailable: "Домовой рядом · основной канал недоступен", }; cognitiveStatus.dataset.state = displayState; cognitiveStatus.textContent = labels[displayState]; diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 8ffbc45..af74482 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -73,7 +73,7 @@