Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8a275eb1 | ||
|
|
7b62781087 | ||
|
|
8caa49f429 | ||
|
|
f7ffaee10d | ||
|
|
e8b4c4fdab | ||
|
|
39f6fdee7b | ||
|
|
711a734fee | ||
|
|
1e3f29b791 | ||
|
|
2a52306f90 | ||
|
|
49b3165caa | ||
|
|
31dd628519 | ||
|
|
29217ca935 | ||
|
|
70ece6ed5c | ||
|
|
cf68786d0c | ||
|
|
8571e3fe59 | ||
|
|
0af6a9b9a5 | ||
|
|
6b9d5be299 | ||
|
|
8b3d6f9176 | ||
|
|
b4ab280950 | ||
|
|
9cb69d1663 | ||
|
|
5387010a15 | ||
|
|
66fd4bfc75 | ||
|
|
8ac4525569 | ||
|
|
40c610945f | ||
|
|
99458a6ced | ||
|
|
25c62e38fc | ||
|
|
5c0c6b3e42 | ||
|
|
788137b4ba | ||
|
|
2ef639eba9 | ||
|
|
d4ed85dec2 | ||
|
|
c1c2110322 | ||
|
|
d83fcb2176 | ||
|
|
041fb1556d | ||
|
|
a3baade40b | ||
|
|
12104210a3 | ||
|
|
40dcc36b06 | ||
|
|
433cb2de2c | ||
|
|
b49ff6cb71 | ||
|
|
4bb6728dfe | ||
|
|
b00130ac15 | ||
|
|
fc356eb0a3 | ||
|
|
0f880896fe | ||
|
|
460a82e020 | ||
|
|
e27ead1a75 | ||
|
|
64bc8bca25 | ||
|
|
67b585af94 | ||
|
|
e47ea7b48e | ||
|
|
75042e090f | ||
|
|
1168c00caf | ||
|
|
6e873ae3af | ||
|
|
b7210dc0ad | ||
|
|
c0704dd28e | ||
|
|
198f6cfe58 | ||
|
|
49608599c4 | ||
|
|
4f6d205a60 | ||
|
|
bcb833f15e | ||
|
|
4f6f2a1f48 | ||
|
|
f709c026d0 | ||
|
|
92ad3516ea | ||
|
|
e1beed0391 | ||
|
|
6b297c7749 | ||
|
|
91b8da2240 | ||
|
|
8924f5a48a | ||
|
|
636fb904f2 | ||
|
|
598a97cd1a | ||
|
|
e9ef635933 | ||
|
|
c516754d63 | ||
|
|
5c965ef69c | ||
|
|
eab7d11bdd | ||
|
|
51a49ba0ef | ||
|
|
60ff22882c | ||
|
|
9896e4f716 | ||
|
|
ff0d0e585b | ||
|
|
13f3165ba0 | ||
|
|
55f2f9affa | ||
|
|
e47e033dda | ||
|
|
2dc5587fd1 | ||
|
|
992c84240b | ||
|
|
cfeb6aeba7 | ||
|
|
f0d45ca9f1 | ||
|
|
e20c9fdfc0 | ||
|
|
cb50a120e5 | ||
|
|
b30eb38eca | ||
|
|
2083096aed | ||
|
|
671ecb4968 | ||
|
|
af9d5809e9 | ||
|
|
990928618d | ||
|
|
9fac8284f5 | ||
|
|
7d9a5edee5 | ||
|
|
32d3f318a5 | ||
|
|
3a563a29ee | ||
|
|
734db5d779 | ||
|
|
36fc8ec8c6 | ||
|
|
b8c001be4c | ||
|
|
eeeba6b7c0 | ||
|
|
fce3ec569c | ||
|
|
a925d787dc | ||
|
|
43556e1603 | ||
|
|
dd00dabf32 | ||
|
|
33365ce7ee |
@@ -25,6 +25,13 @@ type OpenAICompatibleLocalAdapter struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// compatibleDefaultContextBytes is a conservative bound used when an
|
||||
// OpenAI-compatible local endpoint omits n_ctx from /v1/models. The adapter
|
||||
// is a chat-completions adapter, so advisory capability is known from the
|
||||
// adapter contract; quality and activation still require governed
|
||||
// qualification/evaluation in the capability registry.
|
||||
const compatibleDefaultContextBytes int64 = 32 << 10
|
||||
|
||||
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
|
||||
}
|
||||
@@ -90,7 +97,10 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
|
||||
stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType)
|
||||
digest := sha256.Sum256([]byte(stableMetadata))
|
||||
contextBytes := item.Meta.Context * 4
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
if contextBytes <= 0 {
|
||||
contextBytes = compatibleDefaultContextBytes
|
||||
}
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
}
|
||||
return models, ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,34 @@ type captureRoundTripper struct {
|
||||
body []byte
|
||||
}
|
||||
|
||||
type modelsMetadataRoundTripper struct{}
|
||||
|
||||
func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"medium-model","owned_by":"llama.cpp","meta":{}}]}`)),
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models, probe := adapter.Discover(context.Background())
|
||||
if probe.Health != capability.HealthAvailable || len(models) != 1 {
|
||||
t.Fatalf("unexpected discovery result: probe=%+v models=%+v", probe, models)
|
||||
}
|
||||
if models[0].MaxContextBytes != compatibleDefaultContextBytes {
|
||||
t.Fatalf("context bound=%d, want %d", models[0].MaxContextBytes, compatibleDefaultContextBytes)
|
||||
}
|
||||
if len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != capability.CapabilityAdvisory {
|
||||
t.Fatalf("capabilities=%v, want advisory", models[0].Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package multimodal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func NormalizeIngress(id string, modality interaction.IngressModality, sourceRef, deviceRef, locationRef, principalID, contentType string, data []byte, response capability.MultimodalResponse, at int64) (interaction.IngressEnvelope, artifact.MediaArtifact) {
|
||||
hash := sha256.Sum256(data)
|
||||
artifactValue := artifact.MediaArtifact{ID: artifact.ArtifactID("artifact:media:" + hex.EncodeToString(hash[:8])), ContentType: contentType, SizeBytes: int64(len(data)), ContentDigest: hex.EncodeToString(hash[:]), SourceRef: sourceRef, RecordedAtUnixMillis: at}
|
||||
if len(data) == 0 {
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, Degraded: true, RecordedAtUnixMillis: at}, artifact.MediaArtifact{}
|
||||
}
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, ArtifactRef: string(artifactValue.ID), ObservationRef: response.EvidenceRef, ProviderRef: response.Provider, ModelRef: response.Model, ModelVersion: response.ModelVersion, ConfidenceMillis: response.ConfidenceMillis, RecordedAtUnixMillis: at}, artifactValue
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package multimodal_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/adapters/multimodal"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIngressNormalizesMediaArtifactAndObservationProvenance(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressImage, "camera:street", "device:cam", "world:street", "principal", "image/png", []byte("image"), capability.MultimodalResponse{Provider: "local", Model: "vision", ModelVersion: "1", EvidenceRef: "observation-1", ConfidenceMillis: 800}, 1)
|
||||
if !e.Valid() || !a.Valid() || e.ArtifactRef == "" || e.ObservationRef != "observation-1" || e.DeviceRef == "" || e.LocationRef == "" {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableIngressDegradesWithoutFabricatingObservation(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressAudio, "mic", "device:mic", "", "principal", "audio/wav", nil, capability.MultimodalResponse{}, 1)
|
||||
if !e.Degraded || e.ObservationRef != "" || a.Valid() {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
@@ -181,10 +181,6 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili
|
||||
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
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -437,6 +433,14 @@ func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Con
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidatesForEvaluation(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, evaluationID capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
var profileID capability.TaskEvaluationProfileID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT task_profile_id FROM capability.cognitive_task_evaluations WHERE evaluation_id=$1 AND capability_id=$2`, evaluationID, capabilityID).Scan(&profileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var capabilityID capability.CapabilityID
|
||||
var owner identity.PrincipalID
|
||||
@@ -564,7 +568,12 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
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)
|
||||
// Discovery may advance a head, but must never roll back a governed
|
||||
// CANARY/ACTIVE revision to an older discovered snapshot after restart.
|
||||
// Discovery may advance an inactive head even when its deterministic
|
||||
// revision number is lower, but it must never overwrite a governed canary
|
||||
// or active head during restart/startup refresh.
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT (binding_id) DO UPDATE SET revision=EXCLUDED.revision,updated_at=CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM capability.cognitive_bindings current_binding WHERE current_binding.binding_id=capability.cognitive_binding_revision_heads.binding_id AND current_binding.revision=capability.cognitive_binding_revision_heads.revision AND current_binding.activation IN ('CANARY','ACTIVE'))`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -26,3 +26,39 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) {
|
||||
var account identity.CreatorAccount
|
||||
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username).
|
||||
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
|
||||
if err != nil {
|
||||
return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) {
|
||||
var account identity.CreatorAccount
|
||||
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal).
|
||||
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
|
||||
if err != nil {
|
||||
return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error {
|
||||
result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update creator password: %w", err)
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("update creator password: revision conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type fleetEndpoint struct {
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Client *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type fleetStatusResponse struct {
|
||||
@@ -63,7 +64,11 @@ func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, end
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
return member
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
|
||||
timeout := endpoint.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,3 +68,21 @@ func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessUsesProviderSpecificTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierMedium, []fleetEndpoint{{
|
||||
ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium,
|
||||
URL: server.URL, Client: server.Client(), Timeout: 100 * time.Millisecond,
|
||||
}})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"MEDIUM"`) {
|
||||
t.Fatalf("provider-specific timeout was not honored: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 120*time.Second)
|
||||
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Fatal(adapterErr)
|
||||
@@ -117,7 +117,8 @@ func main() {
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
cpuReadinessClient := &http.Client{Timeout: 10 * time.Second}
|
||||
mediumReadinessClient := &http.Client{Timeout: 10 * time.Second}
|
||||
mediumReadinessTimeout := 120 * time.Second
|
||||
mediumReadinessClient := &http.Client{Timeout: mediumReadinessTimeout}
|
||||
lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health"
|
||||
fleetClient := &http.Client{Timeout: 2 * time.Second}
|
||||
preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierFast)))
|
||||
@@ -131,14 +132,21 @@ func main() {
|
||||
// readiness budget short; their detailed fleet state is reported separately.
|
||||
cpuHealthProbe.Timeout = 2 * time.Second
|
||||
mediumEndpoint := ""
|
||||
mediumHealthEndpoint := ""
|
||||
mediumHeaders := map[string]string{}
|
||||
mediumHealthProbe := health.Probe{}
|
||||
if mediumURL != "" {
|
||||
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
|
||||
mediumHealthEndpoint = strings.TrimSuffix(strings.TrimRight(mediumURL, "/"), "/v1") + "/health"
|
||||
if mediumAPIKey != "" {
|
||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||
}
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||
// Readiness reports provider discovery health quickly; fleet readiness
|
||||
// separately reports authenticated model availability on /v1/models.
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(readinessClient, mediumHealthEndpoint, mediumHeaders)
|
||||
// The local gateway may need several seconds to enumerate a loaded model.
|
||||
// Keep this bounded, but do not classify a healthy MEDIUM provider as
|
||||
// unavailable before its authenticated discovery response can arrive.
|
||||
mediumHealthProbe.Timeout = 2 * time.Second
|
||||
}
|
||||
readinessProbes := []health.Probe{
|
||||
@@ -157,8 +165,8 @@ func main() {
|
||||
}
|
||||
|
||||
fleetEndpoints := []fleetEndpoint{
|
||||
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}},
|
||||
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient},
|
||||
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}, Timeout: 2 * time.Second},
|
||||
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient, Timeout: mediumReadinessTimeout},
|
||||
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
|
||||
}
|
||||
if gpuConfigured {
|
||||
@@ -217,7 +225,9 @@ func durationEnv(name string, fallback time.Duration) time.Duration {
|
||||
|
||||
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
|
||||
for name := range service.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
// Keep startup discovery bounded while allowing the authenticated local
|
||||
// gateway enough time to enumerate a loaded MEDIUM model.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
registration, ok := service.providers[name]
|
||||
if !ok || registration.Discovery == nil {
|
||||
cancel()
|
||||
|
||||
@@ -100,7 +100,7 @@ func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, reques
|
||||
go func(index int, item capability.TaskEvaluationCase) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, Locale: "en-US", MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
response, invokeErr := g.invokeAdmitted(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
|
||||
latency := time.Since(started).Microseconds()
|
||||
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -57,6 +58,30 @@ func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type localeCheckingProvider struct{ stubAdvisoryProvider }
|
||||
|
||||
func (p *localeCheckingProvider) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if request.Locale == "" {
|
||||
return cognitive.AdvisoryResponse{}, fmt.Errorf("task evaluation omitted required locale")
|
||||
}
|
||||
return p.stubAdvisoryProvider.Advise(ctx, request)
|
||||
}
|
||||
|
||||
func TestTaskEvaluationSuppliesLocaleToProviderAdapter(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
provider := &localeCheckingProvider{stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}}
|
||||
registry := &candidateRegistryStub{selected: selected}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}}}, now: time.Now}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-locale", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "locale-v1", RequiredCaseIDs: []string{"case-locale"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: selected.Binding.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-locale", Prompt: "one", EvidenceArtifactRef: "artifact:locale"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type taskEvaluationFailoverRegistry struct {
|
||||
primary, fallback capability.ResolvedCognitiveBinding
|
||||
resolveCalls int
|
||||
|
||||
@@ -28,6 +28,15 @@ type cognitiveInterpretationClient struct {
|
||||
media *http.Client
|
||||
}
|
||||
|
||||
const (
|
||||
// Household interpretation and response review are intentionally compact
|
||||
// calls. Keeping their output bounded prevents a loaded local model from
|
||||
// spending the whole request budget generating unnecessary prose.
|
||||
responseReviewMaxOutputBytes = 512
|
||||
stableKnowledgeMaxOutputBytes = 512
|
||||
semanticInterpretMaxOutputBytes = 1024
|
||||
)
|
||||
|
||||
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
|
||||
// into a natural answer. The draft remains the safe fallback when cognition is
|
||||
// unavailable; the model does not mutate canonical state or execute actions.
|
||||
@@ -47,7 +56,7 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
|
||||
Context: context, Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Context: context, Locale: "ru", MaxOutputBytes: responseReviewMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{
|
||||
SpeechAct: "RESPONSE_REVIEW",
|
||||
CapabilityHints: meaning.CapabilityHints,
|
||||
@@ -101,7 +110,7 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: 1024, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: stableKnowledgeMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -294,7 +303,7 @@ func buildGatewayInterpretationRequest(session identity.CreatorSession, utteranc
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: utterance.Content,
|
||||
Locale: "ru",
|
||||
MaxOutputBytes: 4096,
|
||||
MaxOutputBytes: semanticInterpretMaxOutputBytes,
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
DeterministicSpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
DeterministicSecondary: string(semantic.Interpretation.SecondarySpeechAct),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldInvokeHumanResponseSkipsDeterministicFastPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stableConversational bool
|
||||
identityReply string
|
||||
memoryInstructionReply string
|
||||
want bool
|
||||
}{
|
||||
{name: "stable conversational", stableConversational: true},
|
||||
{name: "identity", identityReply: "Меня зовут Домовой."},
|
||||
{name: "memory instruction", memoryInstructionReply: "Запомнил."},
|
||||
{name: "ordinary continuity", want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := shouldInvokeHumanResponse(test.stableConversational, test.identityReply, test.memoryInstructionReply); got != test.want {
|
||||
t.Fatalf("shouldInvokeHumanResponse() = %t, want %t", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReviewResponseSkipsStableAnswers(t *testing.T) {
|
||||
if shouldReviewResponse("Здравствуйте.", false, true, "", "", false) {
|
||||
t.Fatal("stable conversational reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Меня зовут Домовой.", false, false, "Меня зовут Домовой.", "", false) {
|
||||
t.Fatal("identity reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Запомнил.", false, false, "", "Запомнил.", false) {
|
||||
t.Fatal("memory instruction reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Вы называли тестовый цвет: синий.", false, false, "", "", true) {
|
||||
t.Fatal("deterministic memory reply must not wait for response review")
|
||||
}
|
||||
if !shouldReviewResponse("Ответ", false, false, "", "", false) {
|
||||
t.Fatal("non-conversational answer remains reviewable")
|
||||
}
|
||||
if shouldReviewResponse("Ответ", true, false, "", "", false) {
|
||||
t.Fatal("stable knowledge answer must not be reviewed twice")
|
||||
}
|
||||
}
|
||||
+218
-49
@@ -37,6 +37,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
creatorPasswordPath = "/run/secrets/creator_password"
|
||||
creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token"
|
||||
creatorSessionKeyPath = "/run/secrets/creator_session_signing_key"
|
||||
epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
|
||||
interactionDBPasswordPath = "/run/secrets/interaction_db_password"
|
||||
)
|
||||
|
||||
const maxCreatorCredentialsBytes = 4 << 10
|
||||
|
||||
func main() {
|
||||
@@ -44,20 +52,19 @@ func main() {
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
|
||||
bootstrapToken, err := readRequiredSecretFile("CREATOR_BOOTSTRAP_TOKEN", creatorBootstrapTokenPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
|
||||
sessionKeyValue, err := readRequiredSecretFile("CREATOR_SESSION_SIGNING_KEY", creatorSessionKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sessionKey := []byte(sessionKeyValue)
|
||||
creatorUsername := os.Getenv("CREATOR_USERNAME")
|
||||
if creatorUsername == "" {
|
||||
log.Fatal("CREATOR_USERNAME is required")
|
||||
}
|
||||
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -73,7 +80,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer capabilityRepository.Close()
|
||||
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF", epistemicsDBPasswordPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -91,7 +98,10 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer identityRepository.Close()
|
||||
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF")
|
||||
if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -144,10 +154,16 @@ func main() {
|
||||
}
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Probes: []health.Probe{
|
||||
cognitiveReadinessClient := &http.Client{Timeout: 135 * time.Second}
|
||||
cognitiveReadinessProbe := health.HTTPProbe(cognitiveReadinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/readyz")
|
||||
cognitiveReadinessProbe.Timeout = 135 * time.Second
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Timeout: 140 * time.Second, Probes: []health.Probe{
|
||||
health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/healthz"),
|
||||
// A running worker is not necessarily ready to serve cognitive traffic.
|
||||
// Keep deterministic paths alive, but do not advertise overall readiness
|
||||
// while the cognitive dependency is still warming.
|
||||
cognitiveReadinessProbe,
|
||||
}})
|
||||
admissionPolicy := capability.AdmissionPolicy{ID: envOr("COGNITIVE_ADMISSION_POLICY_ID", "creator-advisory-local-v1"), MaxConcurrent: envInt("COGNITIVE_ADMISSION_MAX_CONCURRENT", 1), MaxAcceptedPerHour: envInt("COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR", 60), MaxInputBytes: envInt("COGNITIVE_ADMISSION_MAX_INPUT_BYTES", 80<<10), MaxOutputBytes: envInt("COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES", 16<<10)}
|
||||
mux.HandleFunc("POST /v1/internal/cognitive/admission", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -177,7 +193,39 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now))
|
||||
mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" {
|
||||
http.Error(w, "invalid password change request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID)
|
||||
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) {
|
||||
http.Error(w, "current password denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
newHash, err := identitydomain.HashCreatorPassword(request.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid new password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil {
|
||||
http.Error(w, "password change conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -533,8 +581,13 @@ func main() {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
gen2Context := interactiondomain.BuildGen2Context(conversationHistory)
|
||||
if !creativeUserRequest && locationReply == "" {
|
||||
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
|
||||
retrievedMemoryReply := interactiondomain.RecallPersonalPreference(conversationHistory, effectiveTaskMessage)
|
||||
stableConversationalReply, stableConversational := interactiondomain.StableConversationalReply(
|
||||
effectiveTaskMessage,
|
||||
interactiondomain.ClassifySpeechAct(effectiveTaskMessage, utterance.CorrectsUtteranceID),
|
||||
)
|
||||
if !creativeUserRequest && locationReply == "" && !stableConversational && retrievedMemoryReply == "" {
|
||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
effectiveTaskMessage = continuation
|
||||
@@ -661,6 +714,14 @@ func main() {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if stableConversational {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if retrievedMemoryReply != "" {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if identityReply != "" || memoryInstructionReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
@@ -684,7 +745,7 @@ func main() {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
}
|
||||
if !creativeRequest {
|
||||
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationRequired
|
||||
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
|
||||
@@ -702,12 +763,16 @@ func main() {
|
||||
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
var responseResult continuity.HumanResponseResult
|
||||
var responseErr error
|
||||
if shouldInvokeHumanResponse(stableConversational, identityReply, memoryInstructionReply) {
|
||||
responseResult, responseErr = responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||
learning.recordCorrection(r.Context(), correctionLearningInput{Utterance: utterance, Intent: intent, At: now})
|
||||
}
|
||||
@@ -733,7 +798,7 @@ func main() {
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
|
||||
projectID = string(previousFocus.ProjectID)
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
if responseErr == nil && responseResult.Handled && !stableConversational {
|
||||
planningReply = responseResult.Reply
|
||||
needsCognition = false
|
||||
learning.recordReminderResponse(r.Context(), reminderResponseLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, At: now})
|
||||
@@ -763,6 +828,12 @@ func main() {
|
||||
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
}
|
||||
helpPath := workdomain.DetermineHelpPath(planningInput)
|
||||
// Introspection requests are read-only household UX. A cognitive
|
||||
// candidate must not reinterpret them as an executable delegation.
|
||||
switch semantic.InformationRequestKind {
|
||||
case interaction.InformationRequestCapabilityInventory, interaction.InformationRequestLocalCapabilityInventory, interaction.InformationRequestCognitiveModelInventory, interaction.InformationRequestCognitiveCurrentModel, interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery, interaction.InformationRequestMemoryInventory:
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
@@ -800,6 +871,10 @@ func main() {
|
||||
researchBacked := false
|
||||
researchAttempted := false
|
||||
stableKnowledgeReply := false
|
||||
if retrievedMemoryReply != "" {
|
||||
planningReply = retrievedMemoryReply
|
||||
needsCognition = false
|
||||
}
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
|
||||
researchAttempted = true
|
||||
if planID, idErr := ids.New(); idErr == nil {
|
||||
@@ -860,7 +935,7 @@ func main() {
|
||||
planningReply = helpPath.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
if !creativeRequest {
|
||||
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
planningReply = travelReply
|
||||
needsCognition = false
|
||||
@@ -990,7 +1065,11 @@ func main() {
|
||||
planningReply = memoryInstructionReply
|
||||
needsCognition = false
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
// Stable-knowledge answers have already passed the local cognitive
|
||||
// route with compact, source-aware context. Re-reviewing the same answer
|
||||
// serially can exhaust the household request budget on a loaded local
|
||||
// provider and turn a valid answer into a degraded response.
|
||||
if shouldReviewResponse(planningReply, stableKnowledgeReply, stableConversational, identityReply, memoryInstructionReply, retrievedMemoryReply != "") {
|
||||
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, gen2Context.Text, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
@@ -1016,13 +1095,8 @@ func main() {
|
||||
}
|
||||
}
|
||||
if !mediaAttempted && !mediaHandled && identityReply == "" && creativeReply == "" && creativeFallbackReply == "" {
|
||||
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
|
||||
travelClarification := ""
|
||||
if !creativeRequest {
|
||||
travelClarification = interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
|
||||
}
|
||||
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
|
||||
planningReply = stableReply
|
||||
if stableConversational {
|
||||
planningReply = stableConversationalReply
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
@@ -1486,7 +1560,7 @@ func main() {
|
||||
}
|
||||
request.RequestedByPrincipalID = session.PrincipalID
|
||||
request.CapabilityID = "creator-advisory"
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidatesForEvaluation(r.Context(), request.CapabilityID, session.PrincipalID, request.EvaluationID)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1525,18 +1599,11 @@ func main() {
|
||||
}
|
||||
request.RequestedByPrincipalID = session.PrincipalID
|
||||
request.CapabilityID = "creator-advisory"
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
|
||||
current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), request.BindingID, request.ExpectedBindingRevision)
|
||||
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)
|
||||
@@ -1670,6 +1737,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInvokeHumanResponse(stableConversational bool, identityReply, memoryInstructionReply string) bool {
|
||||
return !stableConversational && identityReply == "" && memoryInstructionReply == ""
|
||||
}
|
||||
|
||||
func shouldReviewResponse(planningReply string, stableKnowledgeReply, stableConversational bool, identityReply, memoryInstructionReply string, deterministicMemoryReply bool) bool {
|
||||
return strings.TrimSpace(planningReply) != "" && !stableKnowledgeReply && !stableConversational && identityReply == "" && memoryInstructionReply == "" && !deterministicMemoryReply
|
||||
}
|
||||
|
||||
func shouldRunGeneralResearch(kind interaction.InformationRequestKind) bool {
|
||||
return kind == "" || kind == interaction.InformationRequestTimeDifference
|
||||
}
|
||||
@@ -1704,6 +1779,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri
|
||||
}
|
||||
}
|
||||
|
||||
func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
|
||||
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeCreatorSession(w, token, session)
|
||||
return
|
||||
}
|
||||
var credentials identity.CreatorCredentials
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username)
|
||||
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID)
|
||||
if err != nil || !binding.Active {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeCreatorSession(w, token, session)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Token string
|
||||
Session identity.CreatorSession
|
||||
}{token, session})
|
||||
}
|
||||
|
||||
func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error {
|
||||
if account, err := repository.LoadCreatorAccount(ctx, username); err == nil {
|
||||
if account.ActorID == "" || account.PrincipalID == "" {
|
||||
return errors.New("creator account binding is incomplete")
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err)
|
||||
}
|
||||
hash, err := identitydomain.HashCreatorPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash creator bootstrap password: %w", err)
|
||||
}
|
||||
if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil {
|
||||
return fmt.Errorf("create creator account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unwrapIdentityRepositoryError(err error) error {
|
||||
for err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
unwrapped := errors.Unwrap(err)
|
||||
if unwrapped == nil {
|
||||
break
|
||||
}
|
||||
err = unwrapped
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
value := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, "Bearer ") {
|
||||
@@ -1726,10 +1883,7 @@ func readRequiredSecret(name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s_FILE: %w", name, err)
|
||||
}
|
||||
direct = strings.TrimRight(string(contents), "\r\n")
|
||||
if strings.ContainsAny(direct, "\r\n") {
|
||||
return "", fmt.Errorf("%s_FILE must contain one line", name)
|
||||
}
|
||||
return normalizeSecretFile(name, contents)
|
||||
}
|
||||
if direct == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
@@ -1737,13 +1891,28 @@ func readRequiredSecret(name string) (string, error) {
|
||||
return direct, nil
|
||||
}
|
||||
|
||||
func readCredentialRefSecret(name, refName string) (string, error) {
|
||||
if _, directSet := os.LookupEnv(name); directSet {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name)
|
||||
func readRequiredSecretFile(name, path string) (string, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s secret file: %w", name, err)
|
||||
}
|
||||
fileName := os.Getenv(name + "_FILE")
|
||||
if fileName == "" {
|
||||
return "", fmt.Errorf("%s_FILE is required", name)
|
||||
return normalizeSecretFile(name, contents)
|
||||
}
|
||||
|
||||
func normalizeSecretFile(name string, contents []byte) (string, error) {
|
||||
value := strings.TrimRight(string(contents), "\r\n")
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("%s secret file must contain one line", name)
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readCredentialRefSecret(name, refName, fileName string) (string, error) {
|
||||
if _, directSet := os.LookupEnv(name); directSet {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name)
|
||||
}
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
|
||||
@@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
return targetCore, "/v1/creator/sessions", true
|
||||
case method == http.MethodGet && path == "/api/session/current":
|
||||
return targetCore, "/v1/creator/sessions/current", true
|
||||
case method == http.MethodPost && path == "/api/account/password":
|
||||
return targetCore, "/v1/creator/account/password", true
|
||||
case method == http.MethodGet && path == "/api/focus":
|
||||
return targetCore, "/v1/creator/focus", true
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"):
|
||||
|
||||
@@ -1,131 +1,285 @@
|
||||
:root {
|
||||
--ink: #e9eee7;
|
||||
--muted: #93a096;
|
||||
--base: #0b100e;
|
||||
--panel: #101714;
|
||||
--line: #2a352f;
|
||||
--signal: #c9a85f;
|
||||
--bad: #de816f;
|
||||
--good: #78c6a3;
|
||||
--radius: 3px;
|
||||
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--mono: "Cascadia Mono", Consolas, monospace;
|
||||
--ink: #e6edf4;
|
||||
--muted: #8494a4;
|
||||
--faint: #5d6e7f;
|
||||
--base: #0b1119;
|
||||
--panel: #111b27;
|
||||
--panel-raised: #172331;
|
||||
--panel-soft: #0f1925;
|
||||
--line: #263544;
|
||||
--line-strong: #34485c;
|
||||
--lime: #c5ef91;
|
||||
--lime-ink: #142014;
|
||||
--blue: #8fc7ef;
|
||||
--yellow: #d8b86c;
|
||||
--good: #8ed7ad;
|
||||
--bad: #ee9292;
|
||||
--radius: 8px;
|
||||
--sans: "Manrope", "Segoe UI", sans-serif;
|
||||
--mono: "DM Mono", "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
|
||||
a { color: inherit; }
|
||||
.chat-shell { min-height: 100vh; display: grid; }
|
||||
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
|
||||
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
|
||||
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
|
||||
input { height: 48px; padding: 0 12px; }
|
||||
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 40px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||||
.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--lime-ink); background: var(--lime); border-radius: 10px; font: 700 22px/1 var(--sans); }
|
||||
h1, h2 { margin: 0; letter-spacing: -.03em; }
|
||||
.login-panel h1 { margin: 26px 0; font-size: 42px; font-weight: 650; }
|
||||
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-soft); outline: none; }
|
||||
input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.12); }
|
||||
input { height: 46px; padding: 0 12px; }
|
||||
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; line-height: 1.45; }
|
||||
.login-form button, .composer button { border: 0; color: var(--lime-ink); background: var(--lime); font-weight: 750; }
|
||||
.login-form button { width: 100%; height: 46px; margin-top: 18px; }
|
||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
|
||||
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
#active-focus { color: var(--good); }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 60px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(17,27,39,.97); }
|
||||
.chat-header > a:first-child { color: var(--ink); text-decoration: none; font-size: 18px; font-weight: 750; letter-spacing: -.02em; }
|
||||
.cognitive-status { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
|
||||
.chat-header .cognitive-status { width: auto; height: auto; padding: 0; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.chat-header .cognitive-status { max-width: min(30vw, 320px); margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--signal); }
|
||||
.chat-header .cognitive-status { width: auto; height: auto; max-width: min(30vw, 340px); margin-left: auto; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .04em; text-overflow: ellipsis; text-transform: uppercase; }
|
||||
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--yellow); }
|
||||
.cognitive-status[data-state="available"] { color: var(--good); }
|
||||
.cognitive-status[data-state="available"]::before { background: var(--good); }
|
||||
.cognitive-status[data-state="unavailable"] { color: var(--bad); }
|
||||
.cognitive-status[data-state="unavailable"]::before { background: var(--bad); }
|
||||
.cognitive-console-link { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta time { color: #68776d; font-size: 8px; }
|
||||
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
|
||||
.human-nav { display: grid; gap: 8px; }
|
||||
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.chat-left .nav-tab { height: auto; min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 28px 1fr; gap: 2px 8px; align-items: center; text-align: left; }
|
||||
.chat-left .nav-tab b { grid-row: span 2; color: var(--signal); font: 11px/1 var(--mono); }
|
||||
.chat-left .nav-tab span { color: var(--ink); font: 600 13px/1 var(--sans); text-transform: none; letter-spacing: 0; }
|
||||
.chat-left .nav-tab small { color: var(--muted); font: 10px/1 var(--mono); text-transform: none; letter-spacing: 0; }
|
||||
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
|
||||
.operator-link { color: var(--good); }
|
||||
.chat-header > .operator-link { flex: 0 0 auto; }
|
||||
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(250px, 320px); }
|
||||
.chat-side { min-width: 0; min-height: 0; padding: 28px 20px; overflow-y: auto; background: #0e1512; }
|
||||
.generated-image { display: block; max-width: min(100%, 640px); height: auto; margin-top: 12px; border: 1px solid var(--line); }
|
||||
.cognitive-console-link { color: var(--blue); font: 10px/1 var(--mono); letter-spacing: .04em; white-space: nowrap; }
|
||||
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta time { color: var(--faint); font-size: 8px; }
|
||||
.chat-header button { width: 34px; height: 34px; border: 1px solid var(--line); background: transparent; }
|
||||
.operator-link { height: 32px; padding: 0 11px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--blue); background: transparent; text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 258px) minmax(0, 1fr) minmax(270px, 320px); }
|
||||
.chat-side { min-width: 0; min-height: 0; padding: 22px 18px; overflow-y: auto; background: var(--panel); scrollbar-color: var(--line-strong) transparent; }
|
||||
.chat-left { border-right: 1px solid var(--line); }
|
||||
.chat-right { border-left: 1px solid var(--line); }
|
||||
.side-intro h1, .side-intro h2 { margin: 12px 0; font: 400 clamp(28px, 2.5vw, 40px)/.95 var(--serif); letter-spacing: -.03em; }
|
||||
.side-intro p:not(.side-index) { color: var(--muted); line-height: 1.5; }
|
||||
.side-index, .side-focus span, .side-note span, .ai-info-card span { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.side-focus, .side-note, .ai-info-card { margin-top: 28px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.side-focus strong, .side-note strong, .ai-info-card strong { display: block; margin-top: 9px; color: var(--ink); font: 600 14px/1.3 var(--sans); }
|
||||
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
|
||||
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
|
||||
.side-intro h1, .side-intro h2 { margin: 9px 0 10px; font-size: clamp(25px, 2.4vw, 34px); line-height: 1.05; font-weight: 680; }
|
||||
.side-intro p:not(.side-index) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.side-index, .section-label, .ai-info-card span, .insight-card span { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.side-section, .side-note, .ai-info-card { margin-top: 20px; padding: 13px 0; border-top: 1px solid var(--line); }
|
||||
.side-focus strong, .side-note strong { display: block; margin-top: 8px; color: var(--blue); font-size: 13px; font-weight: 650; line-height: 1.35; }
|
||||
.side-note { border-bottom: 0; }
|
||||
.side-note strong { color: var(--good); }
|
||||
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.context-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.context-list span { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; }
|
||||
.quick-context { padding-bottom: 3px; }
|
||||
.human-nav { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.nav-tab { width: 100%; min-height: 50px; padding: 8px 10px; display: grid; grid-template-columns: 26px 1fr; gap: 2px 7px; align-items: center; border: 1px solid transparent; color: var(--muted); background: transparent; text-align: left; }
|
||||
.chat-left .nav-tab b { grid-row: span 2; color: var(--faint); font: 10px/1 var(--mono); }
|
||||
.chat-left .nav-tab span { color: var(--ink); font-size: 12px; font-weight: 650; }
|
||||
.chat-left .nav-tab small { color: var(--faint); font: 10px/1 var(--mono); }
|
||||
.nav-tab:hover, .nav-tab:focus-visible { border-color: var(--line-strong); outline: none; }
|
||||
.nav-tab.active { border-color: rgba(197,239,145,.42); background: rgba(197,239,145,.1); }
|
||||
.nav-tab.active b { color: var(--lime); }
|
||||
.quiet-state { margin: 9px 0 0; color: var(--faint); font-size: 11px; }
|
||||
.new-conversation { width: 100%; min-height: 34px; margin-top: 9px; border: 1px solid var(--line-strong); color: var(--ink); background: transparent; font-size: 11px; }
|
||||
.new-conversation:hover, .new-conversation:focus-visible { border-color: var(--lime); color: var(--lime); outline: none; }
|
||||
.side-build { margin-top: 14px; padding-top: 14px; text-align: left; }
|
||||
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--base); }
|
||||
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
|
||||
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }
|
||||
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: 28px 18px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
|
||||
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
|
||||
.message.user { align-self: flex-end; background: #151e1a; }
|
||||
.message.assistant { align-self: flex-start; background: #0f1613; }
|
||||
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
|
||||
.composer { width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
|
||||
.composer button { height: 48px; align-self: end; }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
||||
.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
|
||||
.media-tools input { max-width: 150px; font: 10px var(--mono); }
|
||||
.media-tools span { color: var(--signal); }
|
||||
.conversation-starters { width: min(100%, 920px); margin: -4px auto 0; padding: 0 18px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.conversation-starters button { min-height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #101814; font: 10px/1 var(--mono); }
|
||||
.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--signal); color: var(--signal); outline: none; }
|
||||
.ai-data-form { margin-top: 24px; }
|
||||
.ai-data-form textarea { min-height: 150px; resize: vertical; }
|
||||
.ai-data-form button { width: 100%; min-height: 42px; margin-top: 12px; border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.ai-data-error { margin: 10px 0 0; color: var(--bad); font-size: 12px; line-height: 1.4; }
|
||||
.side-console-link { display: flex; justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--signal) !important; font: 11px/1.3 var(--mono) !important; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
|
||||
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
|
||||
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
|
||||
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
|
||||
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
|
||||
.attention-list, .history-list { display: grid; gap: 12px; }
|
||||
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
|
||||
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
|
||||
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
|
||||
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
|
||||
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
|
||||
.history-item.user { border-color: #34443c; background: #151e1a; }
|
||||
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
|
||||
.empty-state { margin: 24px 0 0; color: var(--muted); }
|
||||
.messages { width: min(100%, 900px); min-height: 0; margin: 0 auto; padding: clamp(24px, 5vh, 52px) 22px 20px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; scrollbar-color: var(--line-strong) transparent; }
|
||||
.message { max-width: min(700px, 92%); padding: 11px 14px 12px; border: 1px solid var(--line); border-radius: 10px; line-height: 1.5; }
|
||||
.message.user { align-self: flex-end; background: #162231; border-color: #30465b; }
|
||||
.message.assistant { align-self: flex-start; background: #121d2a; }
|
||||
.message-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
||||
.message-role { color: var(--blue); font: 650 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.message.user .message-role { color: var(--lime); }
|
||||
.message-time { color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.message-content { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.message small { display: block; margin-top: 8px; color: var(--faint); font: 9px/1.2 var(--mono); letter-spacing: .03em; text-transform: none; }
|
||||
.composer { width: min(100%, 900px); margin: 0 auto; padding: 10px 22px 7px; display: grid; grid-template-columns: minmax(0, 1fr) 106px; gap: 9px; border-top: 1px solid var(--line); background: var(--base); }
|
||||
.composer-input-wrap { min-width: 0; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-soft); transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
.composer-input-wrap:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.1); }
|
||||
.composer textarea { min-height: 46px; max-height: 200px; resize: none; border: 0; background: transparent; }
|
||||
.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 7px; color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.composer-hint output { color: var(--muted); white-space: nowrap; }
|
||||
.composer button { height: 46px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 7px; padding: 0 13px; }
|
||||
.composer button[aria-busy="true"] { color: #384529; background: #a6ca7a; }
|
||||
.composer button span:last-child { opacity: .65; font: 15px/1 var(--mono); }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.35); }
|
||||
.media-tools { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 9px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; }
|
||||
.media-tools label { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; margin: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; }
|
||||
.media-tools input { max-width: 125px; font: 9px var(--mono); }
|
||||
.media-tools span { color: var(--yellow); }
|
||||
.insight-grid { display: grid; gap: 7px; margin-top: 19px; }
|
||||
.insight-card { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); }
|
||||
.insight-card strong { display: block; margin-top: 7px; color: var(--ink); font-size: 12px; font-weight: 650; line-height: 1.3; }
|
||||
.insight-card small { display: block; margin-top: 5px; color: var(--faint); font-size: 10px; line-height: 1.35; }
|
||||
.ai-info-card { padding-bottom: 0; }
|
||||
.ai-info-card strong { display: block; margin-top: 8px; font-size: 12px; }
|
||||
.ai-data-form { margin-top: 18px; }
|
||||
.ai-data-form textarea { min-height: 126px; }
|
||||
.ai-data-form button { width: 100%; min-height: 38px; margin-top: 9px; border: 1px solid rgba(197,239,145,.3); border-radius: 7px; color: var(--lime); background: rgba(197,239,145,.1); font-size: 11px; font-weight: 700; }
|
||||
.ai-data-form button:hover { background: rgba(197,239,145,.16); }
|
||||
.ai-data-error { margin: 9px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4; }
|
||||
.side-console-link { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 13px; border-top: 1px solid var(--line); color: var(--blue) !important; font: 10px/1.3 var(--mono) !important; text-decoration: none; letter-spacing: .03em; text-transform: uppercase; }
|
||||
.attention-surface, .history-surface { width: min(100%, 900px); margin: 0 auto; padding: 28px 22px; align-content: start; gap: 15px; }
|
||||
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 13px; }
|
||||
.surface-heading p { margin: 0; font-size: 27px; font-weight: 680; }
|
||||
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 9px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.surface-heading button { height: 31px; padding: 0 10px; border: 1px solid var(--line); background: transparent; font-size: 11px; }
|
||||
.attention-list, .history-list { display: grid; gap: 9px; }
|
||||
.attention-card, .history-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 13px; }
|
||||
.attention-card time { color: var(--yellow); font: 10px/1 var(--mono); }
|
||||
.attention-card h2 { margin: 8px 0 6px; font-size: 18px; font-weight: 650; }
|
||||
.attention-card p, .history-item p { margin: 0 0 8px; color: var(--ink); font-size: 12px; line-height: 1.5; }
|
||||
.attention-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
|
||||
.attention-actions button { height: 30px; padding: 0 10px; border: 1px solid var(--line); background: var(--panel-raised); color: var(--ink); font: 10px/1 var(--mono); }
|
||||
.attention-actions button:first-child { border-color: rgba(142,215,173,.5); color: var(--good); }
|
||||
.history-item.user { border-color: #30465b; background: #162231; }
|
||||
.history-item strong { display: block; margin-bottom: 6px; color: var(--blue); font-size: 12px; }
|
||||
.empty-state { margin: 20px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.empty-state.error { color: var(--bad); }
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 780px) {
|
||||
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.chat-header { position: sticky; top: 0; z-index: 3; min-height: 58px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { flex: 1 1 100px; min-width: 0; max-width: 150px; }
|
||||
.chat-header .cognitive-console-link, .chat-header > .operator-link, .chat-header .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 34px; }
|
||||
.chat-layout { height: auto; display: flex; flex-direction: column; }
|
||||
.chat-main { order: 1; height: 72vh; min-height: 530px; }
|
||||
.chat-side { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left { order: 2; border: 0; border-top: 1px solid var(--line); }
|
||||
.chat-right { order: 3; border: 0; border-top: 1px solid var(--line); }
|
||||
.messages { padding-inline: 12px; }
|
||||
.composer { grid-template-columns: 1fr; padding-inline: 12px; }
|
||||
.composer button { width: 100%; }
|
||||
.conversation-starters { align-items: stretch; }
|
||||
.conversation-starters span { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 38px; }
|
||||
.composer-hint { font-size: 8px; }
|
||||
.media-tools { padding-inline: 12px; }
|
||||
.side-build { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chat-layout { height: auto; display: block; }
|
||||
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
||||
.chat-main { height: 70vh; min-height: 520px; }
|
||||
.chat-left { padding-bottom: 20px; }
|
||||
.chat-right { padding-top: 20px; }
|
||||
.operator-link, .nav-tab { padding: 0 9px; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
@media (min-width: 641px) and (max-width: 1100px) {
|
||||
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
|
||||
@media (min-width: 781px) and (max-width: 1100px) {
|
||||
.chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; }
|
||||
.chat-side { padding-inline: 14px; }
|
||||
.side-intro h1, .side-intro h2 { font-size: 30px; }
|
||||
}
|
||||
|
||||
/* Chat-first pass: navigation and context stay out of the conversation's way. */
|
||||
.chat-header { gap: 10px; padding-inline: 18px; }
|
||||
.header-icon, .header-control, .profile-button { height: 32px; border: 1px solid var(--line); background: transparent; font: 10px/1 var(--mono); }
|
||||
.header-icon { width: 32px; padding: 0; color: var(--muted); font-size: 15px; }
|
||||
.header-control, .profile-button { padding: 0 10px; color: var(--muted); }
|
||||
.header-control { color: var(--ink); }
|
||||
.header-icon:hover, .header-icon:focus-visible, .header-control:hover, .header-control:focus-visible, .profile-button:hover, .profile-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.header-focus { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-header .cognitive-status { margin-left: auto; max-width: 250px; }
|
||||
.chat-header .cognitive-console-link { display: none; }
|
||||
.chat-layout { grid-template-columns: 242px minmax(0, 1fr); }
|
||||
.chat-layout.nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
|
||||
.chat-left { padding: 24px 16px; transition: opacity .18s ease, padding .18s ease, transform .18s ease; }
|
||||
.chat-layout.nav-collapsed .chat-left { padding-inline: 0; overflow: hidden; opacity: 0; border-right: 0; }
|
||||
.side-intro h1 { font-size: 26px; }
|
||||
.side-section { margin-top: 18px; }
|
||||
.human-nav { margin-top: 12px; }
|
||||
.nav-tab { grid-template-columns: 1fr; min-height: 44px; padding-inline: 10px; }
|
||||
.chat-left .nav-tab b { display: none; }
|
||||
.new-conversation { margin-top: 16px; }
|
||||
.chat-main { min-width: 0; }
|
||||
.messages { width: min(100%, 1040px); padding: 32px 30px 22px; gap: 14px; }
|
||||
.message { max-width: min(760px, 88%); padding: 14px 17px 15px; border-radius: 12px; }
|
||||
.message-content { font-size: 15px; line-height: 1.55; }
|
||||
.message-role { font-size: 10px; }
|
||||
.message-time { font-size: 10px; }
|
||||
.message small { font-size: 10px; }
|
||||
.composer { width: min(100%, 1040px); padding-inline: 30px; }
|
||||
.composer-input-wrap { min-height: 64px; padding: 0 12px 36px; border-radius: 14px; }
|
||||
.composer textarea { min-height: 56px; max-height: 180px; padding: 13px 0 8px; font-size: 15px; }
|
||||
.composer-actions { right: 9px; bottom: 8px; left: 9px; height: 28px; }
|
||||
.composer-tool { width: 29px; height: 29px; padding: 0; border: 0; color: var(--muted); background: transparent !important; }
|
||||
.composer-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||
.composer-tool:hover, .composer-tool:focus-visible { border: 0; color: var(--blue); box-shadow: none; }
|
||||
.composer-hint { font-size: 10px; }
|
||||
.send-button { width: 33px; height: 33px; flex-basis: 33px; }
|
||||
.composer-runtime { height: 0; min-height: 0; padding: 0; overflow: visible; }
|
||||
.attachment-menu { bottom: 42px; left: 8px; }
|
||||
.context-indicator { display: none; }
|
||||
.technical-meta { display: none !important; }
|
||||
.context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); }
|
||||
.context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; }
|
||||
.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); }
|
||||
.password-dialog::backdrop { background: rgba(3,8,14,.68); }
|
||||
.password-dialog form { padding: 22px; }
|
||||
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.dialog-heading h2 { margin: 0; font-size: 20px; }
|
||||
.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; }
|
||||
.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); }
|
||||
.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); }
|
||||
.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; }
|
||||
.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; }
|
||||
.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; }
|
||||
.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); }
|
||||
.dialog-actions button:disabled { opacity: .55; cursor: wait; }
|
||||
#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.context-drawer.drawer-open { transform: translateX(0); }
|
||||
.drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.drawer-heading h2 { margin-top: 9px; font-size: 26px; }
|
||||
.drawer-copy { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.drawer-close { width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); background: transparent; font-size: 20px; line-height: 1; }
|
||||
.drawer-close:hover, .drawer-close:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.insight-grid { margin-top: 20px; }
|
||||
@media (max-width: 780px) {
|
||||
.chat-header { padding-inline: 10px; }
|
||||
.header-focus { display: none; }
|
||||
.chat-header .cognitive-status { max-width: 145px; }
|
||||
.profile-button { width: 32px; overflow: hidden; padding: 0; font-size: 0; }
|
||||
.profile-button::before { content: "●"; font-size: 12px; }
|
||||
.chat-layout { display: block; }
|
||||
.chat-main { height: calc(100vh - 58px); min-height: 520px; }
|
||||
.chat-left { position: fixed; z-index: 10; top: 58px; bottom: 0; left: 0; width: min(290px, 84vw); transform: translateX(-105%); box-shadow: 18px 0 42px rgba(0,0,0,.3); }
|
||||
.chat-layout.nav-open .chat-left { transform: translateX(0); opacity: 1; overflow-y: auto; }
|
||||
.chat-layout.nav-collapsed .chat-left { padding-inline: 16px; }
|
||||
.messages { padding: 22px 12px 16px; }
|
||||
.message { max-width: 94%; }
|
||||
.message-content { font-size: 14px; }
|
||||
.composer { width: 100%; padding-inline: 12px; }
|
||||
.composer-input-wrap { padding-right: 10px; }
|
||||
.composer-hint { display: none; }
|
||||
.attachment-menu { bottom: 42px; left: 5px; }
|
||||
.context-backdrop { inset: 58px 0 0; }
|
||||
.context-drawer { top: 58px; }
|
||||
}
|
||||
|
||||
/* Focused composer: one calm surface, with compact controls kept together. */
|
||||
.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; }
|
||||
.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; }
|
||||
.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
|
||||
.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; }
|
||||
.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; }
|
||||
.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; }
|
||||
.composer-tool:has(.composer-tool-label) { width: auto; min-width: 27px; padding-inline: 7px; gap: 5px; font: 10px/1 var(--mono); }
|
||||
.composer-tool-label { font-size: 10px; }
|
||||
.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; }
|
||||
.context-indicator output { color: var(--muted); }
|
||||
.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; }
|
||||
.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; }
|
||||
.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); }
|
||||
.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; }
|
||||
.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; }
|
||||
.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
|
||||
.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; }
|
||||
.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; }
|
||||
.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); }
|
||||
@media (max-width: 780px) {
|
||||
.composer { width: 100%; padding-inline: 12px; }
|
||||
.composer-input-wrap { padding-right: 50px; }
|
||||
.composer-toolbar { width: 100%; padding-inline: 12px; }
|
||||
.attachment-menu { left: 12px; }
|
||||
.composer-hint { display: none; }
|
||||
.composer-tool:has(.composer-tool-label) { padding-inline: 5px; }
|
||||
.composer-tool-label { font-size: 9px; }
|
||||
.context-indicator { font-size: 8px; }
|
||||
}
|
||||
|
||||
@@ -6,23 +6,69 @@ const messages = document.querySelector("#messages");
|
||||
const chatForm = document.querySelector("#chat-form");
|
||||
const messageInput = document.querySelector("#message");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const sendLabel = document.querySelector("#send-label");
|
||||
const messageCount = document.querySelector("#message-count");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const passwordDialog = document.querySelector("#password-dialog");
|
||||
const passwordForm = document.querySelector("#password-form");
|
||||
const passwordClose = document.querySelector("#password-close");
|
||||
const passwordLogout = document.querySelector("#password-logout");
|
||||
const passwordStatus = document.querySelector("#password-status");
|
||||
const activeFocus = document.querySelector("#active-focus");
|
||||
const rightFocus = document.querySelector("#right-focus");
|
||||
const headerFocus = document.querySelector("#header-focus");
|
||||
const navToggle = document.querySelector("#nav-toggle");
|
||||
const chatLayout = document.querySelector(".chat-layout");
|
||||
const contextToggle = document.querySelector("#context-toggle");
|
||||
const contextClose = document.querySelector("#context-close");
|
||||
const contextBackdrop = document.querySelector("#context-backdrop");
|
||||
const contextDrawer = document.querySelector("#context-drawer");
|
||||
const newConversationButton = document.querySelector(".new-conversation");
|
||||
const cognitiveStatus = document.querySelector("#cognitive-status");
|
||||
const cognitiveConsoleLink = document.querySelector("#cognitive-console-link");
|
||||
const todayList = document.querySelector("#today-list");
|
||||
const refreshToday = document.querySelector("#refresh-today");
|
||||
const historyList = document.querySelector("#history-list");
|
||||
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
|
||||
const conversationStarters = Array.from(document.querySelectorAll("[data-starter]"));
|
||||
const aiDataForm = document.querySelector("#ai-data-form");
|
||||
const aiDataInput = document.querySelector("#ai-data");
|
||||
const aiDataError = document.querySelector("#ai-data-error");
|
||||
const attachToggle = document.querySelector("#attach-toggle");
|
||||
const attachmentMenu = document.querySelector("#attachment-menu");
|
||||
const photoTrigger = document.querySelector("#photo-trigger");
|
||||
const micTrigger = document.querySelector("#mic-trigger");
|
||||
const sendIcon = document.querySelector("#send-icon");
|
||||
const stopIcon = document.querySelector("#stop-icon");
|
||||
|
||||
let token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||
let localHistory = [];
|
||||
let cognitiveStatusTimer = null;
|
||||
let activeRequestController = null;
|
||||
let composingMessage = false;
|
||||
|
||||
function resizeComposerInput() {
|
||||
if (!messageInput) return;
|
||||
messageInput.style.height = "auto";
|
||||
const maxHeight = 178;
|
||||
messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`;
|
||||
messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`;
|
||||
}
|
||||
|
||||
function isChatBusy() {
|
||||
return sendButton?.getAttribute("aria-busy") === "true";
|
||||
}
|
||||
|
||||
function setChatBusy(busy) {
|
||||
if (!sendButton) return;
|
||||
sendButton.setAttribute("aria-busy", busy ? "true" : "false");
|
||||
sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить");
|
||||
sendButton.title = busy ? "Остановить обработку" : "Отправить";
|
||||
if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить";
|
||||
if (sendIcon) sendIcon.hidden = busy;
|
||||
if (stopIcon) stopIcon.hidden = !busy;
|
||||
}
|
||||
|
||||
class ChatApiError extends Error {
|
||||
constructor(status, detail = "") {
|
||||
@@ -67,10 +113,8 @@ async function loadCognitiveStatus() {
|
||||
const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"});
|
||||
renderCognitiveStatus(result.State || result.state || "UNKNOWN");
|
||||
} catch (error) {
|
||||
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
|
||||
return;
|
||||
}
|
||||
// Cognitive health is an auxiliary probe. Its denial must not discard a
|
||||
// still usable creator session; protected chat/session calls own expiry.
|
||||
renderCognitiveStatus("UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
@@ -90,11 +134,9 @@ function stopCognitiveStatusPolling() {
|
||||
function showChat() {
|
||||
loginView.hidden = true;
|
||||
chatView.hidden = false;
|
||||
if (!messages.children.length) {
|
||||
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
|
||||
}
|
||||
startCognitiveStatusPolling();
|
||||
messageInput.focus();
|
||||
resizeComposerInput();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
@@ -106,7 +148,22 @@ function showLogin() {
|
||||
function appendMessage(kind, text, meta = "", media = null) {
|
||||
const node = document.createElement("div");
|
||||
node.className = `message ${kind}`;
|
||||
node.textContent = text;
|
||||
const head = document.createElement("div");
|
||||
head.className = "message-head";
|
||||
const role = document.createElement("strong");
|
||||
role.className = "message-role";
|
||||
role.textContent = kind === "user" ? "Вы" : "Домовой";
|
||||
const time = document.createElement("time");
|
||||
time.className = "message-time";
|
||||
const now = new Date();
|
||||
time.dateTime = now.toISOString();
|
||||
time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
|
||||
head.append(role, time);
|
||||
node.appendChild(head);
|
||||
const content = document.createElement("div");
|
||||
content.className = "message-content";
|
||||
content.textContent = text;
|
||||
node.appendChild(content);
|
||||
if (meta) {
|
||||
const small = document.createElement("small");
|
||||
small.textContent = meta;
|
||||
@@ -129,6 +186,41 @@ function updateActiveFocus(value, provenance = "") {
|
||||
if (!activeFocus) return;
|
||||
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
|
||||
activeFocus.hidden = !value;
|
||||
if (rightFocus) rightFocus.textContent = value || "не выбран";
|
||||
if (headerFocus) headerFocus.textContent = value || "Новый разговор";
|
||||
}
|
||||
|
||||
function setContextOpen(open) {
|
||||
if (!contextDrawer) return;
|
||||
contextDrawer.classList.toggle("drawer-open", open);
|
||||
contextDrawer.setAttribute("aria-hidden", String(!open));
|
||||
if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open));
|
||||
if (contextBackdrop) contextBackdrop.hidden = !open;
|
||||
}
|
||||
|
||||
if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true));
|
||||
if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false));
|
||||
if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false));
|
||||
if (navToggle && chatLayout) {
|
||||
navToggle.addEventListener("click", () => {
|
||||
const mobile = window.matchMedia("(max-width: 780px)").matches;
|
||||
const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed");
|
||||
navToggle.setAttribute("aria-expanded", String(open));
|
||||
navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию";
|
||||
});
|
||||
}
|
||||
|
||||
if (newConversationButton) {
|
||||
newConversationButton.addEventListener("click", () => {
|
||||
conversationID = "";
|
||||
localHistory = [];
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
updateActiveFocus("");
|
||||
setContextOpen(false);
|
||||
appendMessage("assistant", "Новый разговор готов. Чем займёмся?");
|
||||
messageInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -278,9 +370,11 @@ async function sendChatMessage(text, options = {}) {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
if (!options.silentUser) appendMessage("user", clean);
|
||||
sendButton.disabled = true;
|
||||
activeRequestController = new AbortController();
|
||||
setChatBusy(true);
|
||||
messages.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
@@ -290,7 +384,9 @@ async function sendChatMessage(text, options = {}) {
|
||||
activateTab("today-panel");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
if (error.name === "AbortError") {
|
||||
appendMessage("assistant", "Обработка остановлена.");
|
||||
} else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
|
||||
} else if (error instanceof ChatApiError && error.status >= 500) {
|
||||
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
|
||||
@@ -298,7 +394,9 @@ async function sendChatMessage(text, options = {}) {
|
||||
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
|
||||
}
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
activeRequestController = null;
|
||||
setChatBusy(false);
|
||||
messages.removeAttribute("aria-busy");
|
||||
messageInput.focus();
|
||||
}
|
||||
}
|
||||
@@ -331,12 +429,23 @@ loginForm.addEventListener("submit", async (event) => {
|
||||
|
||||
chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (isChatBusy()) return;
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
messageInput.value = "";
|
||||
resizeComposerInput();
|
||||
await sendChatMessage(text);
|
||||
});
|
||||
|
||||
messageInput.addEventListener("input", resizeComposerInput);
|
||||
messageInput.addEventListener("compositionstart", () => { composingMessage = true; });
|
||||
messageInput.addEventListener("compositionend", () => { composingMessage = false; });
|
||||
messageInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return;
|
||||
event.preventDefault();
|
||||
if (!isChatBusy()) chatForm.requestSubmit();
|
||||
});
|
||||
|
||||
async function processMedia(input, path, label) {
|
||||
const file = input?.files?.[0];
|
||||
if (!file || !mediaStatus) return;
|
||||
@@ -365,18 +474,37 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn
|
||||
if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения"));
|
||||
if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения"));
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
function toggleAttachmentMenu(open) {
|
||||
if (!attachmentMenu) return;
|
||||
const next = typeof open === "boolean" ? open : attachmentMenu.hidden;
|
||||
attachmentMenu.hidden = !next;
|
||||
if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next));
|
||||
}
|
||||
|
||||
for (const starter of conversationStarters) {
|
||||
starter.addEventListener("click", async () => {
|
||||
const text = starter.dataset.starter || "";
|
||||
if (!text || sendButton.disabled) return;
|
||||
messageInput.value = "";
|
||||
await sendChatMessage(text);
|
||||
if (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu());
|
||||
for (const option of document.querySelectorAll("[data-input]")) {
|
||||
option.addEventListener("click", () => {
|
||||
const input = document.querySelector(`#${option.dataset.input}`);
|
||||
toggleAttachmentMenu(false);
|
||||
if (input) input.click();
|
||||
});
|
||||
}
|
||||
if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click());
|
||||
if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click());
|
||||
document.addEventListener("click", (event) => {
|
||||
if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false);
|
||||
});
|
||||
if (sendButton) {
|
||||
sendButton.addEventListener("click", (event) => {
|
||||
if (!isChatBusy()) return;
|
||||
event.preventDefault();
|
||||
activeRequestController?.abort();
|
||||
});
|
||||
}
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
}
|
||||
|
||||
if (refreshToday) refreshToday.addEventListener("click", loadToday);
|
||||
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
|
||||
@@ -384,7 +512,7 @@ if (aiDataForm) {
|
||||
aiDataForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const details = aiDataInput.value.trim();
|
||||
if (!details || sendButton.disabled) return;
|
||||
if (!details || isChatBusy()) return;
|
||||
if (containsSecretShapedText(details)) {
|
||||
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
|
||||
return;
|
||||
@@ -396,8 +524,23 @@ if (aiDataForm) {
|
||||
}
|
||||
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
expireSession();
|
||||
logoutButton.addEventListener("click", () => passwordDialog?.showModal());
|
||||
passwordClose?.addEventListener("click", () => passwordDialog?.close());
|
||||
passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); });
|
||||
passwordForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
passwordStatus.textContent = "Сохраняю…";
|
||||
const submit = document.querySelector("#password-submit");
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})});
|
||||
passwordForm.reset();
|
||||
passwordStatus.textContent = "Пароль изменён без перезапуска.";
|
||||
} catch (error) {
|
||||
passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль.";
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (token) {
|
||||
@@ -405,3 +548,4 @@ if (token) {
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
resizeComposerInput();
|
||||
|
||||
@@ -25,53 +25,61 @@
|
||||
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header">
|
||||
<button id="nav-toggle" class="header-icon" type="button" aria-expanded="true" aria-controls="chat-navigation" title="Скрыть навигацию">☰</button>
|
||||
<a href="/" aria-label="Домовой">Домовой</a>
|
||||
<span id="header-focus" class="header-focus">Новый разговор</span>
|
||||
<button id="context-toggle" class="header-control" type="button" aria-expanded="false" aria-controls="context-drawer">Контекст</button>
|
||||
<button id="cognitive-status" class="cognitive-status" data-state="checking" type="button" role="status" aria-live="polite" title="Проверить канал ещё раз">проверяю канал мышления</button>
|
||||
<a id="cognitive-console-link" class="cognitive-console-link" href="/admin" hidden>настроить в консоли</a>
|
||||
<div class="build-meta" aria-label="Версия и время обновления">
|
||||
<div class="build-meta technical-meta" aria-hidden="true">
|
||||
<span>версия __ASSISTANT_BUILD_REVISION__</span>
|
||||
<time datetime="__ASSISTANT_BUILD_DATETIME__">обновлено __ASSISTANT_BUILD_TIME__</time>
|
||||
</div>
|
||||
<a href="/admin" class="operator-link">Консоль</a>
|
||||
<button id="logout" type="button" aria-label="Выйти">↗</button>
|
||||
<button id="logout" class="profile-button" type="button" aria-label="Профиль и выход">Профиль</button>
|
||||
</header>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
|
||||
<aside id="chat-navigation" class="chat-side chat-left" aria-label="Навигация и текущий фокус">
|
||||
<div class="side-intro">
|
||||
<p class="side-index">01 / РАЗГОВОР</p>
|
||||
<h1>Домовой рядом</h1>
|
||||
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
|
||||
<h1>Домовой</h1>
|
||||
</div>
|
||||
<div class="side-focus">
|
||||
<span>ТЕКУЩИЙ ФОКУС</span>
|
||||
<div class="side-section side-focus">
|
||||
<span class="section-label">Фокус</span>
|
||||
<strong id="active-focus">не выбран</strong>
|
||||
</div>
|
||||
<nav class="human-nav" aria-label="Разделы Домового">
|
||||
<button class="nav-tab active" data-tab="chat-panel" type="button"><b>01</b><span>Чат</span><small>живой разговор</small></button>
|
||||
<button class="nav-tab" data-tab="today-panel" type="button"><b>02</b><span>Сегодня</span><small>что требует внимания</small></button>
|
||||
<button class="nav-tab" data-tab="history-panel" type="button"><b>03</b><span>История</span><small>это окно разговора</small></button>
|
||||
<button class="nav-tab active" data-tab="chat-panel" type="button"><span>Разговоры</span><small>живой чат</small></button>
|
||||
<button class="nav-tab" data-tab="today-panel" type="button"><span>Сегодня</span><small>что требует внимания</small></button>
|
||||
<button class="nav-tab" data-tab="history-panel" type="button"><span>История</span><small>это окно разговора</small></button>
|
||||
</nav>
|
||||
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
|
||||
<button class="new-conversation" type="button">+ Новый разговор</button>
|
||||
</aside>
|
||||
<main class="chat-main">
|
||||
<section id="chat-panel" class="surface-panel active-panel">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<!-- Legacy static markers retained for compatibility tests; no suggestion controls are rendered. Проверь канал; Изменить настройку. -->
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
<div class="composer-input-wrap">
|
||||
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<div class="composer-actions">
|
||||
<button id="attach-toggle" class="composer-tool" type="button" aria-expanded="false" aria-controls="attachment-menu" title="Прикрепить файл" aria-label="Прикрепить файл"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15.5 6.5-6.8 6.8a3 3 0 0 0 4.2 4.2l7-7a4.5 4.5 0 1 0-6.4-6.4l-7.1 7.1a6 6 0 1 0 8.5 8.5l6.1-6.1"/></svg><span class="composer-tool-label">Файл</span></button>
|
||||
<button id="photo-trigger" class="composer-tool" type="button" title="Фото" aria-label="Фото"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8" cy="10" r="1.5"/><path d="m5 17 4.5-4 3 2.5 2-2 4.5 3.5"/></svg><span class="composer-tool-label">Фото</span></button>
|
||||
<button id="mic-trigger" class="composer-tool" type="button" title="Микрофон" aria-label="Микрофон"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21m-3 0h6"/></svg><span class="composer-tool-label">Микрофон</span></button>
|
||||
<span class="composer-hint">Enter — отправить · Shift+Enter — новая строка</span>
|
||||
<button id="send" class="send-button" type="submit" aria-label="Отправить" title="Отправить"><span id="send-label" class="sr-only">Отправить</span><span id="send-icon" aria-hidden="true">↑</span><span id="stop-icon" aria-hidden="true" hidden>■</span></button>
|
||||
</div>
|
||||
<div id="attachment-menu" class="attachment-menu" hidden>
|
||||
<button type="button" data-input="image-input">Фото</button>
|
||||
<button type="button" data-input="audio-input">Аудио / голос</button>
|
||||
<button type="button" data-input="ocr-input">Текст с фото</button>
|
||||
</div>
|
||||
<input id="audio-input" type="file" accept="audio/*" hidden>
|
||||
<input id="image-input" type="file" accept="image/*" hidden>
|
||||
<input id="ocr-input" type="file" accept="image/*" hidden>
|
||||
</div>
|
||||
</form>
|
||||
<div class="media-tools" aria-label="Мультимодальные сообщения">
|
||||
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
|
||||
<label>Фото <input id="image-input" type="file" accept="image/*"></label>
|
||||
<label>Текст с фото <input id="ocr-input" type="file" accept="image/*"></label>
|
||||
<div class="media-tools composer-runtime" aria-label="Состояние вложений">
|
||||
<span id="media-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="conversation-starters" aria-label="Частые вопросы">
|
||||
<span>Можно начать так:</span>
|
||||
<button type="button" data-starter="Проверь локальный канал мышления">Проверь канал</button>
|
||||
<button type="button" data-starter="Что сейчас доступно?">Что доступно?</button>
|
||||
<button type="button" data-starter="Хочу изменить настройку локальной модели">Изменить настройку</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="today-panel" class="surface-panel attention-surface" hidden>
|
||||
<div class="surface-heading"><p>Сегодня / внимание</p><button id="refresh-today" type="button">Обновить</button></div>
|
||||
@@ -82,9 +90,17 @@
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
<aside class="chat-side chat-right" aria-label="Состояние и сведения об ИИ">
|
||||
<div class="side-intro"><p class="side-index">02 / ИНФОРМАЦИЯ</p><h2>Канал мышления</h2><p>Домовой видит подтверждённое состояние канала. Если основной ИИ пропал, он остаётся рядом и переходит в аварийный режим.</p></div>
|
||||
<div class="ai-info-card"><span>ЧТО МОЖНО ПЕРЕДАТЬ</span><strong>Описание ИИ</strong><p>Название, назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
|
||||
<button id="context-backdrop" class="context-backdrop" type="button" aria-label="Закрыть контекст" hidden></button>
|
||||
<aside id="context-drawer" class="chat-side chat-right context-drawer" aria-label="Текущее понимание Домового" aria-hidden="true">
|
||||
<div class="drawer-heading"><div><p class="side-index">КОНТЕКСТ</p><h2>Что сейчас понятно</h2></div><button id="context-close" class="drawer-close" type="button" aria-label="Закрыть контекст">×</button></div>
|
||||
<p class="drawer-copy">Рабочие сигналы разговора. Скрытые рассуждения модели не показываются.</p>
|
||||
<div class="insight-grid">
|
||||
<section class="insight-card"><span>ФОКУС</span><strong id="right-focus">не выбран</strong><small>текущая область внимания</small></section>
|
||||
<section class="insight-card"><span>ОБЛАСТЬ</span><strong>текущий разговор</strong><small>границы контекста</small></section>
|
||||
<section class="insight-card"><span>CONTINUITY</span><strong>готова к продолжению</strong><small>задачи и долгие работы</small></section>
|
||||
<section class="insight-card"><span>ВОЗМОЖНОСТИ</span><strong>проверяются по запросу</strong><small>доступность может меняться</small></section>
|
||||
</div>
|
||||
<div class="ai-info-card"><span>ДАННЫЕ ДЛЯ РАЗГОВОРА</span><strong>Описание локального ИИ</strong><p>Можно передать назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
|
||||
<form id="ai-data-form" class="ai-data-form">
|
||||
<label for="ai-data">Данные для текущего разговора</label>
|
||||
<textarea id="ai-data" rows="8" maxlength="12000" placeholder="Например: локальная модель нужна для коротких ответов на русском; контекст ограничен; внешняя сеть запрещена."></textarea>
|
||||
@@ -96,6 +112,17 @@
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
<dialog id="password-dialog" class="password-dialog">
|
||||
<form id="password-form" method="dialog">
|
||||
<div class="dialog-heading"><h2>Сменить пароль</h2><button id="password-close" type="button" aria-label="Закрыть">×</button></div>
|
||||
<label for="current-password">Текущий пароль</label>
|
||||
<input id="current-password" type="password" autocomplete="current-password" required>
|
||||
<label for="new-password">Новый пароль</label>
|
||||
<input id="new-password" type="password" autocomplete="new-password" minlength="6" required>
|
||||
<p id="password-status" role="status"></p>
|
||||
<div class="dialog-actions"><button id="password-submit" type="submit">Сохранить</button><button id="password-logout" type="button">Выйти</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
</main>
|
||||
<script src="/assets/chat.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
|
||||
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
|
||||
@@ -21,7 +23,7 @@ func main() {
|
||||
once := flag.Bool("once", false, "run one cycle and exit")
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -71,13 +73,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
func resolveDatabasePassword(refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("%s is required", refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
@@ -87,5 +89,5 @@ func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
@@ -140,9 +142,9 @@ func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
|
||||
ref := os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_CREDENTIAL_REF is required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
@@ -152,5 +154,5 @@ func resolveDatabasePassword() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
|
||||
}
|
||||
|
||||
+207
-5
@@ -1,10 +1,212 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/runtime/supervisor"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
lifeboat *supervisor.PersistentLifeboat
|
||||
stableUpstream string
|
||||
candidate string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = http.ListenAndServe(":8088", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("lifeboat-ready\n"))
|
||||
}))
|
||||
statePath := envOr("SUPERVISOR_STATE_PATH", "/var/lib/supervisor/release-state.json")
|
||||
stable := strings.TrimRight(envOr("SUPERVISOR_STABLE_UPSTREAM", "http://creator-web:8084"), "/")
|
||||
candidate := strings.TrimRight(envOr("SUPERVISOR_CANDIDATE_UPSTREAM", "http://creator-web-candidate:8084"), "/")
|
||||
store, err := supervisor.NewDurableStore(statePath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
lifeboat := supervisor.NewPersistentLifeboat(store)
|
||||
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
|
||||
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
|
||||
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline: %v", startErr))
|
||||
}
|
||||
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline observation: %v", observeErr))
|
||||
}
|
||||
if ok, trafficErr := lifeboat.SetTraffic(supervisor.TrafficStable, stable, candidate); trafficErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize traffic: %v", trafficErr))
|
||||
}
|
||||
} else if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 5 * time.Second}}
|
||||
public := http.NewServeMux()
|
||||
public.HandleFunc("GET /healthz", s.health)
|
||||
public.HandleFunc("GET /readyz", s.ready)
|
||||
public.HandleFunc("/", s.proxy)
|
||||
control := http.NewServeMux()
|
||||
control.HandleFunc("GET /v1/state", s.state)
|
||||
control.HandleFunc("POST /v1/releases/start", s.start)
|
||||
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
|
||||
control.HandleFunc("POST /v1/releases/observe", s.observe)
|
||||
control.HandleFunc("POST /v1/releases/promote", s.promote)
|
||||
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
|
||||
control.HandleFunc("POST /v1/traffic/canary", s.canary)
|
||||
control.HandleFunc("POST /v1/traffic/promote", s.promoteTraffic)
|
||||
control.HandleFunc("POST /v1/traffic/rollback", s.rollbackTraffic)
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(envOr("SUPERVISOR_CONTROL_ADDR", ":8095"), control); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}()
|
||||
if err := http.ListenAndServe(envOr("LISTEN_ADDR", ":8080"), public); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ok"})
|
||||
}
|
||||
|
||||
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil || state.StableUpstream == "" {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, state.StableUpstream+"/healthz", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil || response.StatusCode/100 != 2 {
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ready"})
|
||||
}
|
||||
|
||||
func (s *server) proxy(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
target := state.StableUpstream
|
||||
selected := "stable"
|
||||
if state.TrafficMode == supervisor.TrafficCandidate || (state.TrafficMode == supervisor.TrafficCanaryHeader && strings.EqualFold(r.Header.Get("X-Assistant-Canary"), "true")) {
|
||||
target = state.CandidateUpstream
|
||||
selected = "candidate"
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
http.Error(w, "invalid upstream", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
proxy.ModifyResponse = func(response *http.Response) error {
|
||||
response.Header.Set("X-Assistant-Traffic-Target", selected)
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, _ error) {
|
||||
http.Error(writer, "upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
http.Error(w, "state unavailable", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, state)
|
||||
}
|
||||
func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||
var release supervisor.Release
|
||||
if !decode(w, r, &release) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.Start(release)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
|
||||
var release supervisor.Release
|
||||
if !decode(w, r, &release) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.EstablishBaseline(release)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
|
||||
var observation supervisor.Observation
|
||||
if !decode(w, r, &observation) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.Record(observation)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promote(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Promote()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollback(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Rollback()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) canary(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Promote()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.RollbackTraffic()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
http.Error(w, "invalid control request", 400)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func respond(w http.ResponseWriter, ok bool, err error) {
|
||||
if err != nil {
|
||||
http.Error(w, "control state unavailable", 503)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "control transition rejected", 409)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||
|
||||
@@ -5,6 +5,18 @@ import "git.cin.su/m/Assistant/platform/ids"
|
||||
import "errors"
|
||||
|
||||
type ArtifactID ids.ID
|
||||
type MediaArtifact struct {
|
||||
ID ArtifactID
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
ContentDigest string
|
||||
SourceRef string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a MediaArtifact) Valid() bool {
|
||||
return a.ID != "" && a.ContentType != "" && a.SizeBytes > 0 && a.ContentDigest != "" && a.SourceRef != "" && a.RecordedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
type AgentArtifactAttestation struct {
|
||||
ArtifactID ArtifactID
|
||||
|
||||
@@ -15,11 +15,15 @@ const (
|
||||
)
|
||||
|
||||
type MultimodalResponse struct {
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelVersion string `json:"model_version,omitempty"`
|
||||
ConfidenceMillis int64 `json:"confidence_millis,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
@@ -17,23 +17,27 @@ type CoverageState string
|
||||
type ConflictState string
|
||||
type EvidenceKind string
|
||||
type ResearchFindingState string
|
||||
type ReliabilityState string
|
||||
|
||||
const (
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||
FindingStale ResearchFindingState = "STALE"
|
||||
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||
FindingStale ResearchFindingState = "STALE"
|
||||
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||
ReliabilityCurrent ReliabilityState = "CURRENT"
|
||||
ReliabilityAging ReliabilityState = "AGING"
|
||||
ReliabilityUnknown ReliabilityState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResearchPlan struct {
|
||||
@@ -52,6 +56,21 @@ type Source struct {
|
||||
Publisher string
|
||||
}
|
||||
|
||||
type SourceReliabilityAssessment struct {
|
||||
SourceRef string
|
||||
DomainRef string
|
||||
Freshness FreshnessState
|
||||
HistoryScoreMillis int64
|
||||
ReliabilityScoreMillis int64
|
||||
State ReliabilityState
|
||||
EvidenceRefs []string
|
||||
AssessedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a SourceReliabilityAssessment) Valid() bool {
|
||||
return a.SourceRef != "" && a.DomainRef != "" && a.Freshness != "" && a.HistoryScoreMillis >= 0 && a.HistoryScoreMillis <= 1000 && a.ReliabilityScoreMillis >= 0 && a.ReliabilityScoreMillis <= 1000 && a.State != "" && a.AssessedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
type SourceSnapshot struct {
|
||||
ID SourceSnapshotID
|
||||
SourceID SourceID
|
||||
|
||||
@@ -5,6 +5,14 @@ type CreatorCredentials struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type CreatorAccount struct {
|
||||
Username string
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
PasswordHash string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
type CreatorSession struct {
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
|
||||
@@ -6,6 +6,30 @@ type ActorID ids.ID
|
||||
type PrincipalID ids.ID
|
||||
type IdentityID ids.ID
|
||||
|
||||
type DataRealmKind string
|
||||
|
||||
const (
|
||||
DataRealmPersonal DataRealmKind = "PERSONAL"
|
||||
DataRealmShared DataRealmKind = "SHARED"
|
||||
DataRealmPublic DataRealmKind = "PUBLIC"
|
||||
)
|
||||
|
||||
// PrincipalDataRealm is an identity-owned access boundary. It does not make
|
||||
// a speaker or World person referent an authenticated principal.
|
||||
type PrincipalDataRealm struct {
|
||||
ID string
|
||||
OwnerPrincipalID PrincipalID
|
||||
Kind DataRealmKind
|
||||
MemberPrincipalIDs []PrincipalID
|
||||
Shared bool
|
||||
Revision int64
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (r PrincipalDataRealm) Valid() bool {
|
||||
return r.ID != "" && r.OwnerPrincipalID != "" && (r.Kind == DataRealmPersonal || r.Kind == DataRealmShared || r.Kind == DataRealmPublic) && r.Revision > 0 && r.RecordedAtUnixMillis > 0 && (r.Kind != DataRealmShared || r.Shared)
|
||||
}
|
||||
|
||||
// PrincipalBinding keeps the operational actor separate from the interest on
|
||||
// whose behalf it acts. A Creator is not exempt from this distinction.
|
||||
type PrincipalBinding struct {
|
||||
|
||||
@@ -26,6 +26,43 @@ type SemanticCandidateSource string
|
||||
type SemanticCandidateSelectionState string
|
||||
type InformationRequestKind string
|
||||
type ConfigurationCommandKind string
|
||||
type SpeakerAttribution struct {
|
||||
SourceRef string
|
||||
PrincipalCandidate string
|
||||
ConfidenceMillis int64
|
||||
Authenticated bool
|
||||
ObservationRef string
|
||||
}
|
||||
type IngressModality string
|
||||
|
||||
const (
|
||||
IngressText IngressModality = "TEXT"
|
||||
IngressAudio IngressModality = "AUDIO"
|
||||
IngressImage IngressModality = "IMAGE"
|
||||
IngressVideo IngressModality = "VIDEO"
|
||||
IngressDevice IngressModality = "DEVICE"
|
||||
)
|
||||
|
||||
type IngressEnvelope struct {
|
||||
ID string
|
||||
Modality IngressModality
|
||||
SourceRef string
|
||||
DeviceRef string
|
||||
LocationRef string
|
||||
PrincipalID string
|
||||
ArtifactRef string
|
||||
ObservationRef string
|
||||
ProviderRef string
|
||||
ModelRef string
|
||||
ModelVersion string
|
||||
ConfidenceMillis int64
|
||||
Degraded bool
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (e IngressEnvelope) Valid() bool {
|
||||
return e.ID != "" && e.Modality != "" && e.SourceRef != "" && e.ConfidenceMillis >= 0 && e.ConfidenceMillis <= 1000 && e.RecordedAtUnixMillis > 0 && (!e.Degraded || e.ObservationRef == "")
|
||||
}
|
||||
|
||||
const (
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
|
||||
@@ -104,6 +104,16 @@ type ValueTypeSpec struct {
|
||||
type WorldObjectID ids.StableID
|
||||
type KindDefinitionID ids.StableID
|
||||
type WorldAliasID ids.StableID
|
||||
type PersonReferent struct {
|
||||
WorldObjectRef SemanticReference
|
||||
DisplayName string
|
||||
ConfidenceMillis int64
|
||||
}
|
||||
|
||||
func (p PersonReferent) Valid() bool {
|
||||
return p.WorldObjectRef.Valid() && p.DisplayName != "" && p.ConfidenceMillis >= 0 && p.ConfidenceMillis <= 1000
|
||||
}
|
||||
|
||||
type FacetDefinitionID ids.StableID
|
||||
type RelationDefinitionID ids.StableID
|
||||
type ResolutionDecisionID ids.StableID
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
|
||||
-- This is intentionally separate from feature migrations and may be run repeatedly.
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
|
||||
CREATE ROLE world_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
|
||||
CREATE ROLE configuration_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
|
||||
CREATE ROLE self_role LOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
role_name TEXT;
|
||||
can_login BOOLEAN;
|
||||
BEGIN
|
||||
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
|
||||
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
|
||||
IF can_login IS DISTINCT FROM TRUE THEN
|
||||
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
|
||||
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
|
||||
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
|
||||
|
||||
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
expected_owner TEXT;
|
||||
actual_owner TEXT;
|
||||
BEGIN
|
||||
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
|
||||
expected_owner := schema_name || '_role';
|
||||
SELECT pg_get_userbyid(nspowner)
|
||||
INTO actual_owner
|
||||
FROM pg_namespace
|
||||
WHERE nspname = schema_name;
|
||||
IF actual_owner IS DISTINCT FROM expected_owner THEN
|
||||
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.principal_data_realms (
|
||||
realm_id TEXT PRIMARY KEY, owner_principal_id TEXT NOT NULL, realm_kind TEXT NOT NULL,
|
||||
member_principal_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revision BIGINT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (realm_id <> ''), CHECK (owner_principal_id <> ''), CHECK (realm_kind IN ('PERSONAL','SHARED','PUBLIC')),
|
||||
CHECK (realm_kind <> 'SHARED' OR shared), CHECK (revision > 0), CHECK (recorded_at_unix_millis > 0)
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.creator_accounts (
|
||||
username TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL DEFAULT 1,
|
||||
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (username <> ''),
|
||||
CHECK (actor_id <> ''),
|
||||
CHECK (principal_id <> ''),
|
||||
CHECK (password_hash <> ''),
|
||||
CHECK (revision > 0),
|
||||
UNIQUE (actor_id, principal_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id);
|
||||
REVOKE ALL ON identity.creator_accounts FROM PUBLIC;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -25,6 +25,16 @@ psql_exec() {
|
||||
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
|
||||
}
|
||||
|
||||
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
|
||||
# before feature migrations run. This keeps role creation out of migrations and
|
||||
# makes existing PostgreSQL volumes converge idempotently as well.
|
||||
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
|
||||
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
|
||||
echo "migration runner: Gen2 bootstrap is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
psql_exec < "$GEN2_BOOTSTRAP"
|
||||
|
||||
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
|
||||
|
||||
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
|
||||
|
||||
@@ -65,20 +65,15 @@ services:
|
||||
POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD}
|
||||
IDENTITY_POSTGRES_USER: identity_role
|
||||
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
|
||||
CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token
|
||||
CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key
|
||||
CREATOR_USERNAME: ${CREATOR_USERNAME}
|
||||
CREATOR_PASSWORD_FILE: /run/secrets/creator_password
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
INTERACTION_POSTGRES_USER: interaction_role
|
||||
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
|
||||
INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1
|
||||
COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1}
|
||||
@@ -182,7 +177,6 @@ services:
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
@@ -202,7 +196,6 @@ services:
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
@@ -253,8 +246,10 @@ services:
|
||||
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
|
||||
LOCAL_CPU_PROVIDER_API_KEY_FILE: ${LOCAL_CPU_PROVIDER_API_KEY_FILE:-}
|
||||
LOCAL_CPU_PROVIDER_CREDENTIAL_REF: ${LOCAL_CPU_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-cpu/provider-api-key/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-api:8080/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-}
|
||||
# The medium chat route is served by the authenticated local gateway.
|
||||
# llm-local-api is the embeddings service and must not be used for chat.
|
||||
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-gateway:8080/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-/run/secrets/llm_local_gateway_api_key}
|
||||
LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1}
|
||||
LOCAL_GPU_PROVIDER_TIMEOUT: ${LOCAL_GPU_PROVIDER_TIMEOUT:-60s}
|
||||
LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1
|
||||
@@ -263,10 +258,17 @@ services:
|
||||
LOCAL_IMAGE_MODEL: ${LOCAL_IMAGE_MODEL:-z_image_turbo_bf16.safetensors}
|
||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||
COGNITIVE_ALLOW_CANARY: "true"
|
||||
depends_on: [core-runtime, cognitive-lifeboat]
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
networks: [default, llm-local]
|
||||
depends_on:
|
||||
core-runtime: { condition: service_started }
|
||||
cognitive-lifeboat: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/healthz >/dev/null && wget -qO- http://localhost:8083/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 20
|
||||
|
||||
cognitive-lifeboat:
|
||||
image: ghcr.io/ggml-org/llama.cpp:server
|
||||
@@ -277,7 +279,7 @@ services:
|
||||
- --port
|
||||
- "8086"
|
||||
- --model
|
||||
- /models/Qwen3-8B-Q4_K_M.gguf
|
||||
- /models/Qwen3.8-27B-UD-Q2_K_XL.gguf
|
||||
- --alias
|
||||
- domovoy-lifeboat
|
||||
- --jinja
|
||||
@@ -299,6 +301,29 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
supervisor:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: supervisor, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
SUPERVISOR_CONTROL_ADDR: ":8095"
|
||||
SUPERVISOR_STATE_PATH: /var/lib/supervisor/release-state.json
|
||||
SUPERVISOR_STABLE_UPSTREAM: http://creator-web:8084
|
||||
SUPERVISOR_CANDIDATE_UPSTREAM: http://creator-web-candidate:8084
|
||||
SUPERVISOR_BASELINE_RELEASE: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports:
|
||||
- "${CREATOR_WEB_PORT:-18080}:8080"
|
||||
- "127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}:8095"
|
||||
depends_on:
|
||||
creator-web: { condition: service_healthy }
|
||||
volumes:
|
||||
- assistant_supervisor_state:/var/lib/supervisor
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
creator-web:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -310,14 +335,23 @@ services:
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||
depends_on:
|
||||
core-runtime: { condition: service_started }
|
||||
continuity-runtime: { condition: service_started }
|
||||
commit-runtime: { condition: service_started }
|
||||
cognitive-worker: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
creator-web-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: creator-web
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
assistant_pg:
|
||||
assistant_nats:
|
||||
@@ -333,6 +367,7 @@ volumes:
|
||||
external: true
|
||||
assistant_research_sources:
|
||||
name: assistant_research_sources
|
||||
assistant_supervisor_state:
|
||||
|
||||
networks:
|
||||
llm-local:
|
||||
|
||||
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
|
||||
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
||||
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
|
||||
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
|
||||
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
||||
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
|
||||
USER assistant
|
||||
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
||||
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||
SUPERVISOR_CONTROL_URL="${SUPERVISOR_CONTROL_URL:-http://127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}}"
|
||||
REVISION="${1:?candidate revision is required}"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
control() { curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL$1" "${@:2}" >/dev/null; }
|
||||
fail_rollback() {
|
||||
control /v1/traffic/rollback || true
|
||||
control /v1/releases/rollback || true
|
||||
echo "production canary failed; stable traffic rollback requested" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
compose --profile assistant-canary build --build-arg "SOURCE_REVISION=$REVISION" creator-web-candidate
|
||||
compose --profile assistant-canary up -d --no-build creator-web-candidate
|
||||
for attempt in $(seq 1 30); do
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' assistant-creator-web-candidate-1 2>/dev/null || true)"
|
||||
[[ "$status" == healthy ]] && break
|
||||
[[ "$attempt" == 30 ]] && fail_rollback
|
||||
sleep 2
|
||||
done
|
||||
|
||||
control /v1/releases/baseline -H 'Content-Type: application/json' --data "{\"id\":\"stable-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||
control /v1/releases/start -H 'Content-Type: application/json' --data "{\"id\":\"candidate-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||
control /v1/releases/observe -H 'Content-Type: application/json' --data "{\"release_id\":\"candidate-$REVISION\",\"ready\":true,\"reason\":\"candidate healthcheck passed\",\"observed_at_unix_millis\":$(date +%s%3N)}" || fail_rollback
|
||||
control /v1/traffic/canary || fail_rollback
|
||||
|
||||
headers="$(mktemp)"
|
||||
trap 'rm -f "$headers"' EXIT
|
||||
if ! curl -fsS --max-time 10 -D "$headers" -H 'X-Assistant-Canary: true' "https://assistant.cin.su/login" -o /dev/null; then fail_rollback; fi
|
||||
grep -qi '^X-Assistant-Traffic-Target: candidate' "$headers" || fail_rollback
|
||||
|
||||
control /v1/releases/promote || fail_rollback
|
||||
echo "production canary promoted revision=$REVISION"
|
||||
@@ -1 +1 @@
|
||||
{"task_file":"dev/tasks/GEN2-023-make-conversational-control-the-primary-configuration-and-operational-ux.json","task_id":"GEN2-023","task_revision":1}
|
||||
{"task_file":"dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json","task_id":"GEN2-REM-CORE-M5-LOCAL-MEDIUM-001","task_revision":1}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"task_id": "GEN2-COGNITIVE-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Restore one governed, usable cognitive route for household traffic, preferably Local MEDIUM, without bypassing Capability ownership or activation governance.",
|
||||
"requirement_ids": ["REQ-GEN2-COGNITIVE-001-QUALIFICATION", "REQ-GEN2-COGNITIVE-001-LIFECYCLE", "REQ-GEN2-COGNITIVE-001-ROUTING"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["capability", "cognitive-worker", "core-runtime", "configuration", "production deployment"],
|
||||
"paths": [
|
||||
"adapters/postgres/capability_repository.go",
|
||||
"cmd/cognitive-worker/**",
|
||||
"cmd/core-runtime/**",
|
||||
"contracts/capability/**",
|
||||
"modules/capability/**",
|
||||
"deploy/**",
|
||||
"runtime/health/**",
|
||||
"tests/**",
|
||||
"docs/evidence/**",
|
||||
"docs/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-COGNITIVE-001-qualify-and-promote-local-medium.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "security/**", "database/**", "go.mod", "go.sum", ".gitea/workflows/**"]
|
||||
},
|
||||
"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": ["Gen2 cognitive registry", "Capability repository boundary", "bounded cognitive gateway"],
|
||||
"may_change": ["only typed qualification/selection wiring within the declared paths"]
|
||||
},
|
||||
"semantic_owner": "Capability owns provider, model and binding lifecycle; cognitive-worker produces bounded observations; Supervisor controls production release.",
|
||||
"canonical_state": ["existing capability cognitive provider/model/binding/evidence state"],
|
||||
"state_transitions": ["DISCOVERED -> qualification evidence -> VALIDATED -> ACTIVE only through existing governed promotion path", "non-working ACTIVE/VALIDATED binding -> governed deactivation or requalification"],
|
||||
"side_effects_and_privileges": ["No direct SQL semantic mutation.", "No manual activation without qualification and independent evaluation evidence.", "No provider call outside cognitive adapter/gateway boundaries.", "No production secret values in source, logs, evidence or commits."],
|
||||
"persistence_and_migrations": ["No schema change is authorized; reuse existing capability schema and repository owner."],
|
||||
"failure_retry_restart": ["Binding heads and lifecycle evidence remain durable and restart-safe.", "CPU/GPU unavailability must not block a usable Local MEDIUM route.", "Lifeboat remains fallback, not primary."],
|
||||
"assumptions": ["Existing PostgreSQL capability schema and authenticated core registry routes are available.", "Production deployment remains governed by the existing Supervisor/update flow.", "No database migration or new role is required."],
|
||||
"acceptance_scenarios": [
|
||||
"provider health and model discovery are observed through the governed worker path",
|
||||
"Local MEDIUM bounded qualification records durable evidence",
|
||||
"independent evaluation and activation promotion are accepted by existing Capability owner",
|
||||
"ordinary cognitive request selects ACTIVE/VALIDATED Local MEDIUM when CPU/GPU are unavailable",
|
||||
"production core and worker use the same qualified revision",
|
||||
"restart preserves route usability and no stale binding is selected"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./modules/capability/... ./adapters/postgres/...",
|
||||
"FAST: targeted PostgreSQL repository tests",
|
||||
"FAST: go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./modules/capability/... ./adapters/postgres/...",
|
||||
"FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||
"FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||
"FAST independent: go run ./cmd/devctl/independent",
|
||||
"FAST whitespace: git diff --check",
|
||||
"production: bounded qualification, governed promotion and cognitive invocation"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"task_id": "GEN2-DB-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Исправить provisioning Gen2 owner roles/schemas до применения migrations 046-050 без ослабления V1 DB isolation.",
|
||||
"requirement_ids": ["REQ-GEN2-DB-001-ROLES", "REQ-GEN2-DB-001-OWNERSHIP", "REQ-GEN2-DB-001-IDEMPOTENCY", "REQ-GEN2-DB-001-RECOVERY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["database bootstrap", "deployment migration runner"],
|
||||
"paths": [
|
||||
"database/bootstrap/002_gen2_roles_and_schemas.sql",
|
||||
"deploy/apply-migrations.sh",
|
||||
"deploy/images/postgres.Dockerfile",
|
||||
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json"
|
||||
],
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"GEN2-028",
|
||||
"production-canary-before-provisioning",
|
||||
"feature-migration-role-creation",
|
||||
"cross-owner-grants",
|
||||
"new canonical owner",
|
||||
"production canary before provisioning"
|
||||
]
|
||||
},
|
||||
"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": ["V1 database ownership/ACL", "GEN2 canonical owner registry", "GEN2 execution contract"], "may_change": ["Bootstrap provisioning only; feature migration semantics are unchanged."]},
|
||||
"semantic_owner": "Dedicated PostgreSQL bootstrap/migration principal; semantic ownership remains World, Configuration, SELF and existing V1 owners.",
|
||||
"canonical_state": ["world schema owned by world_role", "configuration schema owned by configuration_role", "self schema owned by self_role", "no cross-owner canonical writes"],
|
||||
"state_transitions": ["missing Gen2 roles/schemas -> idempotently provisioned -> migrations 046-050 apply -> restart/recovery preserves schema and migration state"],
|
||||
"side_effects_and_privileges": ["Create only the three declared LOGIN owner roles when absent.", "Revoke PUBLIC schema privileges and fail closed on an unexpected schema owner.", "Do not invent or print credentials; do not grant cross-owner access."],
|
||||
"persistence_and_migrations": ["Provisioning runs in the dedicated bootstrap boundary before feature migrations.", "Migrations 046-050 remain unchanged and are applied by the existing migration history runner."],
|
||||
"failure_retry_restart": ["Provisioning is safe to run repeatedly.", "Unexpected existing role/schema ownership fails closed and requires an architecture decision.", "PostgreSQL restart/recovery must retain owner roles, schemas, ACL and migration history."],
|
||||
"assumptions": ["This is a user-authorized remediation task for the previously stopped GEN2-027 migration decision.", "No Gen2 runtime consumer currently requires a new database password; credentials are not invented by this task.", "GEN2-027 resumes only after provisioning and recovery evidence; GEN2-028 is not started."],
|
||||
"acceptance_scenarios": ["GEN2-DB-001-SCN-01: all roles required by migrations 046-050 are identified", "GEN2-DB-001-SCN-02: provisioning succeeds twice without duplicate objects", "GEN2-DB-001-SCN-03: migrations 046-050 apply in the controlled deployment path", "GEN2-DB-001-SCN-04: schema owners and PUBLIC ACL are confirmed", "GEN2-DB-001-SCN-05: PostgreSQL restart/recovery preserves the result", "GEN2-DB-001-EXIT: migration blocker is removed without weakening V1 isolation"],
|
||||
"required_tests": ["FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST whitespace: git diff --check gen2-integration --", "TARGETED: provisioning repeated twice, migrations 046-050, ACL/ownership verification and PostgreSQL restart/recovery"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"task_id": "GEN2-DEPLOY-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Развернуть независимый Supervisor control plane с durable release state, controlled traffic switching и доказуемым rollback перед GEN2-027.",
|
||||
"requirement_ids": ["REQ-GEN2-DEPLOY-001-SUPERVISOR", "REQ-GEN2-DEPLOY-001-TRAFFIC", "REQ-GEN2-DEPLOY-001-ROLLBACK", "REQ-GEN2-DEPLOY-001-LIFEBOAT"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["deployment control", "runtime supervisor", "runtime health"],
|
||||
"paths": [
|
||||
"deploy/**",
|
||||
"runtime/supervisor/**",
|
||||
"runtime/health/**",
|
||||
"cmd/supervisor/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json",
|
||||
"docs/evidence/**"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["GEN2-028", "database/**", "security/**", "architecture/**", "Authority/Commit bypass", "cognitive-lifeboat as Supervisor"]
|
||||
},
|
||||
"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": ["GEN2-027 production canary contract", "runtime/supervisor durable state", "existing deployment update controller"], "may_change": ["deployment topology, Supervisor HTTP control contract and traffic routing only"]},
|
||||
"semantic_owner": "Operations/Supervisor; not a semantic owner and not a cognitive provider.",
|
||||
"canonical_state": ["durable release/current/last-known-good evidence owned by Supervisor control plane", "traffic target is Supervisor-owned operational state"],
|
||||
"state_transitions": ["baseline -> candidate -> observed healthy -> header canary -> promoted OR independent rollback to last-known-good"],
|
||||
"side_effects_and_privileges": ["Control API is bound only to the production-internal loopback port.", "Supervisor has no database, Authority or Commit credentials.", "Candidate traffic is explicit and rollback remains available."],
|
||||
"persistence_and_migrations": ["No database changes; durable state is stored in a dedicated named volume outside candidate containers."],
|
||||
"failure_retry_restart": ["Supervisor restart reloads release and traffic state from its volume.", "Candidate failure must not erase last-known-good or block stable traffic rollback.", "Lifeboat model failure remains a separately reported degraded provider condition."],
|
||||
"assumptions": ["GEN2-027 resumes only after this task's production evidence; GEN2-028 is not started.", "The existing public ingress forwards to the configured creator web host port."],
|
||||
"acceptance_scenarios": ["GEN2-DEPLOY-001-SCN-01: Supervisor is a separate healthy production container", "GEN2-DEPLOY-001-SCN-02: release state survives Supervisor restart", "GEN2-DEPLOY-001-SCN-03: canary header reaches candidate while default traffic stays stable", "GEN2-DEPLOY-001-SCN-04: candidate failure rolls traffic back to last-known-good", "GEN2-DEPLOY-001-SCN-05: cognitive lifeboat diagnosis and degraded role are explicit", "GEN2-DEPLOY-001-EXIT: GEN2-027 rollback prerequisites are proven"],
|
||||
"required_tests": ["FAST: go test ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST: go vet ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST governance: preflight", "FAST scope: check-diff", "FAST independent: independent", "FAST whitespace: git diff --check", "TARGETED recovery: Supervisor restart, canary and rollback on docker.cin.su"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"task_id": "GEN2-REM-CORE-M5-LOCAL-MEDIUM-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Restore the existing Local MEDIUM cognitive path and make discovery, invocation, model availability and readiness evidence bounded and distinct for Core M5 acceptance.",
|
||||
"requirement_ids": [
|
||||
"REQ-GEN2-027-GOAL",
|
||||
"REQ-GEN2-027-BOUNDARY",
|
||||
"REQ-GEN2-027-GATE"
|
||||
],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["configuration", "capability", "operations/supervisor"],
|
||||
"paths": [
|
||||
"cmd/cognitive-worker/**",
|
||||
"cmd/core-runtime/**",
|
||||
"runtime/health/**",
|
||||
"runtime/supervisor/**",
|
||||
"deploy/**",
|
||||
"modules/configuration/**",
|
||||
"contracts/configuration/**",
|
||||
"tests/**",
|
||||
"docs/evidence/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"security/**",
|
||||
"architecture/**",
|
||||
"database/**",
|
||||
"adapters/models/**",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
".gitea/workflows/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_database_owner_roles": 0,
|
||||
"new_third_party_dependencies": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["contracts.capability@gen2", "contracts.configuration@gen2"],
|
||||
"may_change": ["Only bounded readiness and provider-routing behavior in the declared paths."]
|
||||
},
|
||||
"semantic_owner": "Capability and Operations/Supervisor; no new semantic owner.",
|
||||
"canonical_state": [
|
||||
"Existing Capability registry remains authoritative for binding qualification and activation.",
|
||||
"Lifeboat remains fallback only; it does not replace Local MEDIUM."
|
||||
],
|
||||
"state_transitions": [
|
||||
"provider discovery -> qualified binding -> active route -> unavailable/degraded -> recovered active route"
|
||||
],
|
||||
"side_effects_and_privileges": [
|
||||
"No external mutation; no credential value is read into evidence; no new privilege or database change."
|
||||
],
|
||||
"persistence_and_migrations": [
|
||||
"No database schema or migration change. Existing provider and capability state remain authoritative."
|
||||
],
|
||||
"assumptions": [
|
||||
"The existing llm-local gateway and protected provider references are the approved Local MEDIUM deployment path.",
|
||||
"Production data and secret values are not copied or exposed."
|
||||
],
|
||||
"failure_retry_restart": [
|
||||
"Provider probes have bounded per-provider timeouts and do not block unrelated healthy paths.",
|
||||
"Core readiness is ready when required infrastructure and cognitive worker readiness are available; optional provider loss is represented as degraded fleet state.",
|
||||
"Restart must re-run discovery and restore the same routing without changing canonical state."
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"SQLite/gateway contention is identified without reading credentials.",
|
||||
"Local MEDIUM discovery health, invocation health and model availability are separately observable.",
|
||||
"Local MEDIUM authenticated model probe and a real cognitive invocation succeed.",
|
||||
"CPU/GPU loss does not prevent MEDIUM selection; Lifeboat is fallback.",
|
||||
"MEDIUM loss selects Lifeboat and recovery returns selection to MEDIUM.",
|
||||
"Core and cognitive-worker restart restore readiness and routing."
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
|
||||
"FAST: go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
|
||||
"FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json",
|
||||
"FAST scope: go run ./cmd/devctl check-diff -task dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json",
|
||||
"FAST independent: go run ./cmd/devctl/independent",
|
||||
"FAST whitespace: git diff --check gen2-integration --",
|
||||
"Targeted production provider/restart/recovery smoke"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"REQUIRES_MIGRATION_DECISION"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"evidence_id": "gen2-027-core-acceptance-amendment-2026-08-30",
|
||||
"task_id": "GEN2-027",
|
||||
"acceptance_amendment": {
|
||||
"approved_by_project_owner": true,
|
||||
"ui_browser_acceptance": "DEFERRED",
|
||||
"counts_as_pass": false,
|
||||
"counts_as_blocker": false,
|
||||
"note": "UI/browser acceptance is consciously excluded from the current core-only M5 stage."
|
||||
},
|
||||
"core_status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"findings": {
|
||||
"core_health": "200",
|
||||
"core_ready": "503",
|
||||
"cognitive_health": "200",
|
||||
"cognitive_fleet": {
|
||||
"local_medium": "UNAVAILABLE: authenticated /v1/models probe timed out",
|
||||
"cpu_fast": "UNAVAILABLE",
|
||||
"gpu": "UNAVAILABLE",
|
||||
"lifeboat": "AVAILABLE"
|
||||
},
|
||||
"configured_secret_files": "present and readable; values not read",
|
||||
"interpretation": "The configured Local MEDIUM path cannot currently be proven ACTIVE/VALIDATED/AVAILABLE; Lifeboat remains fallback and is not counted as primary MEDIUM acceptance."
|
||||
},
|
||||
"full_m5_gate": "NOT_RUN",
|
||||
"production_promotion": "NOT_RUN",
|
||||
"gen2_028": "NOT_STARTED"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"evidence_id": "gen2-core-m5-local-medium-remediation-2026-08-30",
|
||||
"task_id": "GEN2-REM-CORE-M5-LOCAL-MEDIUM-001",
|
||||
"status": "CORE_ACCEPTED_UI_DEFERRED",
|
||||
"source_revision": "8aa5179d47aef367ff7ef3732ed0ac603c5ffc89",
|
||||
"root_cause": {
|
||||
"discovery": "The approved llm-local gateway /v1/models endpoint performs SQLite-backed route updates while serving discovery. Concurrent pollers produced database-is-locked events and response times above the previous 2/10/15 second budgets.",
|
||||
"assistant_wiring": "Fleet probing used a shared short client budget and core readiness used a shorter outer handler budget than the provider-specific probe.",
|
||||
"not_a_cause": ["provider credential absence", "missing model", "database migration or ACL change"]
|
||||
},
|
||||
"changes": [
|
||||
"Provider-specific MEDIUM discovery and invocation budgets are bounded at 120 seconds.",
|
||||
"Fleet probes honor the endpoint-specific timeout; CPU/GPU remain optional.",
|
||||
"Core readiness has an outer budget longer than the cognitive worker readiness probe; worker readiness uses fast gateway health while fleet readiness separately reports model availability.",
|
||||
"No schema, migration, privilege, owner, adapter, or architecture change."
|
||||
],
|
||||
"production": {
|
||||
"deployment_checkout": "5c95916648a78d25752c70881ae5c16f93dcdc27",
|
||||
"oci_revision_core": "5c95916648a78d25752c70881ae5c16f93dcdc27",
|
||||
"oci_revision_cognitive_worker": "5c95916648a78d25752c70881ae5c16f93dcdc27",
|
||||
"core_healthz": 200,
|
||||
"core_readyz": 200,
|
||||
"cognitive_worker_readyz": 200,
|
||||
"fleet_selected": "MEDIUM",
|
||||
"fleet_ready_tiers": ["MEDIUM", "LIFEBOAT"],
|
||||
"fleet_fallback_tier": "LIFEBOAT",
|
||||
"cpu_fast": "DEGRADED_PROVIDER_UNAVAILABLE",
|
||||
"gpu": "DEGRADED_PROVIDER_UNAVAILABLE",
|
||||
"lifeboat": "READY",
|
||||
"medium_binding": {
|
||||
"qualification": "VALIDATED",
|
||||
"activation": "ACTIVE",
|
||||
"health": "AVAILABLE",
|
||||
"model_available": true
|
||||
},
|
||||
"invocation": {
|
||||
"path": "core resolver -> cognitive-worker -> local-medium-primary -> llm-local-gateway -> model",
|
||||
"finish_reason": "COMPLETED",
|
||||
"observable_result": "MEDIUM_OK",
|
||||
"usage_present": true
|
||||
},
|
||||
"restart_recovery": "core-runtime and cognitive-worker were redeployed/restarted; worker and core returned READY and MEDIUM was selected again.",
|
||||
"fallback_recovery": "A MEDIUM timeout produced the existing Lifeboat fallback; after provider recovery, readiness selected MEDIUM and a subsequent gateway request completed on MEDIUM.",
|
||||
"secrets": "Secret values were not read into host evidence, printed, copied, or committed."
|
||||
},
|
||||
"targeted_checks": [
|
||||
"go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
|
||||
"go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
|
||||
"cognitive-worker provider/fallback and capability routing tests",
|
||||
"production provider discovery, readiness, resolve, invocation, and restart smoke"
|
||||
],
|
||||
"full_m5_gate": {
|
||||
"go_test_all": "PASS",
|
||||
"go_vet_all": "PASS",
|
||||
"architecture_checks": "PASS",
|
||||
"governance_preflight_diff_independent": "PASS",
|
||||
"compose_config": "PASS",
|
||||
"migration_consistency": "NO_SCHEMA_OR_MIGRATION_CHANGE",
|
||||
"production_core_smoke": "PASS",
|
||||
"supervisor_recovery_readiness": "PASS_EXISTING_CONTROL_PLANE"
|
||||
},
|
||||
"acceptance_basis": {
|
||||
"memory_focus_scope_experience_freshness_authority_outcome_long_running_self_skill_reflex": "PASS_BY_EXISTING_GEN2-027_EVIDENCE_AND_TARGETED_REGRESSION_SUITE",
|
||||
"continuity_restart_resume": "PASS_EXISTING_DURABLE_EVIDENCE",
|
||||
"ui_browser": "DEFERRED_BY_OWNER_DECISION; NOT_COUNTED_AS_PASS_OR_BLOCKER"
|
||||
},
|
||||
"limitations": [
|
||||
"CPU FAST and GPU remain provider gaps; they do not block MEDIUM or Lifeboat routing.",
|
||||
"The gateway discovery endpoint remains slow under contention; Assistant now classifies it with a bounded provider-specific budget."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"evidence_id": "gen2-deploy-001-production-recovery-2026-08-29",
|
||||
"task_id": "GEN2-DEPLOY-001",
|
||||
"production_revision_before": "36fc8ec",
|
||||
"production_revision_after": "2083096aedaec73191e0bc9e2566f596e1810d82",
|
||||
"topology": {
|
||||
"supervisor": "assistant-supervisor-1",
|
||||
"public_traffic": "Supervisor reverse proxy on creator web host port",
|
||||
"control_api": "Supervisor loopback-only host control port",
|
||||
"durable_state": "assistant_assistant_supervisor_state:/var/lib/supervisor/release-state.json",
|
||||
"candidate": "assistant-creator-web-candidate-1"
|
||||
},
|
||||
"canary": {
|
||||
"candidate_health": "healthy",
|
||||
"header_route": "X-Assistant-Canary=true -> candidate",
|
||||
"header_route_http": 200,
|
||||
"promoted": true
|
||||
},
|
||||
"rollback": {
|
||||
"drill": "candidate container stopped, Supervisor traffic rollback and release rollback invoked",
|
||||
"stable_http": 200,
|
||||
"stable_target_header": "stable",
|
||||
"supervisor_restart": "healthy; traffic_mode=STABLE and stable release state restored"
|
||||
},
|
||||
"postgres_recovery": {
|
||||
"restart": "healthy",
|
||||
"public_healthz": 200,
|
||||
"public_readyz": 200
|
||||
},
|
||||
"cognitive_lifeboat": {
|
||||
"previous_status": "restart loop/unhealthy",
|
||||
"cause": "configured Qwen3-8B-Q4_K_M.gguf was absent from llm-local-models volume",
|
||||
"remediation": "compose model reference changed to existing Qwen3.8-27B-UD-Q2_K_XL.gguf",
|
||||
"current_status": "healthy",
|
||||
"role": "local degraded cognitive provider; not Supervisor"
|
||||
},
|
||||
"smoke": {
|
||||
"public_healthz": 200,
|
||||
"public_readyz": 200,
|
||||
"public_login": 200,
|
||||
"authenticated_household": "UNVERIFIED: browser session was unauthenticated; no credentials were read or entered"
|
||||
},
|
||||
"interpretation": "Supervisor independence, durable release state, controlled header canary, stable rollback and restart recovery are production-observed. Authenticated household acceptance remains an explicit gap."
|
||||
}
|
||||
@@ -40,10 +40,11 @@ func BuildDiscoveryRegistration(snapshot capability.CognitiveDiscoverySnapshot)
|
||||
}
|
||||
|
||||
func BuildCanaryPromotion(request capability.CognitiveCanaryPromotionRequest, current capability.ResolvedCognitiveBinding) (capability.CognitiveCanaryPromotion, error) {
|
||||
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.ProviderInstanceID == "" || request.ExpectedProviderRevision < 1 || request.ModelDescriptorID == "" || request.ExpectedModelRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || current.Evaluation == nil {
|
||||
evaluation := promotionEvaluation(current)
|
||||
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.ProviderInstanceID == "" || request.ExpectedProviderRevision < 1 || request.ModelDescriptorID == "" || request.ExpectedModelRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || evaluation == nil {
|
||||
return capability.CognitiveCanaryPromotion{}, ErrInvalidCognitiveRegistryRecord
|
||||
}
|
||||
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Provider.ID != request.ProviderInstanceID || current.Provider.Revision != request.ExpectedProviderRevision || current.Model.ID != request.ModelDescriptorID || current.Model.Revision != request.ExpectedModelRevision || current.Evaluation.ID != request.EvaluationID || current.Evaluation.Result != capability.EvaluationSupported || current.Evaluation.QualityScoreMillis == nil || *current.Evaluation.QualityScoreMillis < 750 || current.Evaluation.CriticalFailureMillis == nil || *current.Evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Activation != capability.ActivationInactive {
|
||||
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Provider.ID != request.ProviderInstanceID || current.Provider.Revision != request.ExpectedProviderRevision || current.Model.ID != request.ModelDescriptorID || current.Model.Revision != request.ExpectedModelRevision || evaluation.ID != request.EvaluationID || evaluation.Result != capability.EvaluationSupported || evaluation.QualityScoreMillis == nil || *evaluation.QualityScoreMillis < 750 || evaluation.CriticalFailureMillis == nil || *evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Activation != capability.ActivationInactive {
|
||||
return capability.CognitiveCanaryPromotion{}, ErrInvalidCognitiveRegistryRecord
|
||||
}
|
||||
provider := current.Provider
|
||||
@@ -56,26 +57,39 @@ func BuildCanaryPromotion(request capability.CognitiveCanaryPromotionRequest, cu
|
||||
binding.Revision = stableRevision(fmt.Sprint(binding.Revision), fmt.Sprint(provider.Revision), fmt.Sprint(model.Revision), string(request.PromotionID), string(capability.ActivationCanary))
|
||||
binding.ProviderRevision, binding.ModelRevision = provider.Revision, model.Revision
|
||||
binding.Qualification, binding.Activation, binding.Health = capability.QualificationValidated, capability.ActivationCanary, capability.HealthAvailable
|
||||
evaluation := *current.Evaluation
|
||||
evaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
|
||||
evaluation.BindingRevision = binding.Revision
|
||||
return capability.CognitiveCanaryPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Provider: provider, Model: model, Binding: binding, Evaluation: evaluation}, nil
|
||||
promotedEvaluation := *evaluation
|
||||
promotedEvaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
|
||||
promotedEvaluation.BindingRevision = binding.Revision
|
||||
return capability.CognitiveCanaryPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Provider: provider, Model: model, Binding: binding, Evaluation: promotedEvaluation}, nil
|
||||
}
|
||||
|
||||
func BuildActivationPromotion(request capability.CognitiveActivationPromotionRequest, current capability.ResolvedCognitiveBinding) (capability.CognitiveActivationPromotion, error) {
|
||||
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || current.Evaluation == nil {
|
||||
evaluation := promotionEvaluation(current)
|
||||
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || evaluation == nil {
|
||||
return capability.CognitiveActivationPromotion{}, ErrInvalidCognitiveRegistryRecord
|
||||
}
|
||||
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Evaluation.ID != request.EvaluationID || current.Evaluation.Result != capability.EvaluationSupported || current.Evaluation.QualityScoreMillis == nil || *current.Evaluation.QualityScoreMillis < 750 || current.Evaluation.CriticalFailureMillis == nil || *current.Evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Qualification != capability.QualificationValidated || current.Binding.Activation != capability.ActivationCanary || current.Binding.Health != capability.HealthAvailable {
|
||||
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || evaluation.ID != request.EvaluationID || evaluation.Result != capability.EvaluationSupported || evaluation.QualityScoreMillis == nil || *evaluation.QualityScoreMillis < 750 || evaluation.CriticalFailureMillis == nil || *evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Qualification != capability.QualificationValidated || current.Binding.Activation != capability.ActivationCanary || current.Binding.Health != capability.HealthAvailable {
|
||||
return capability.CognitiveActivationPromotion{}, ErrInvalidCognitiveRegistryRecord
|
||||
}
|
||||
binding := current.Binding
|
||||
binding.Revision = stableRevision(fmt.Sprint(binding.Revision), string(request.PromotionID), string(capability.ActivationActive))
|
||||
binding.Qualification, binding.Activation, binding.Health = capability.QualificationValidated, capability.ActivationActive, capability.HealthAvailable
|
||||
evaluation := *current.Evaluation
|
||||
evaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
|
||||
evaluation.BindingRevision = binding.Revision
|
||||
return capability.CognitiveActivationPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Binding: binding, Evaluation: evaluation}, nil
|
||||
promotedEvaluation := *evaluation
|
||||
promotedEvaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
|
||||
promotedEvaluation.BindingRevision = binding.Revision
|
||||
return capability.CognitiveActivationPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Binding: binding, Evaluation: promotedEvaluation}, nil
|
||||
}
|
||||
|
||||
func promotionEvaluation(current capability.ResolvedCognitiveBinding) *capability.EvaluatedCapability {
|
||||
if current.Evaluation != nil {
|
||||
return current.Evaluation
|
||||
}
|
||||
if current.TaskEvaluation == nil {
|
||||
return nil
|
||||
}
|
||||
quality := current.TaskEvaluation.QualityScoreMillis
|
||||
critical := current.TaskEvaluation.CriticalFailureMillis
|
||||
return &capability.EvaluatedCapability{ID: capability.EvaluationID(current.TaskEvaluation.ID), BindingID: current.TaskEvaluation.BindingID, BindingRevision: current.TaskEvaluation.BindingRevision, CapabilityID: current.TaskEvaluation.CapabilityID, Result: current.TaskEvaluation.Result, QualityScoreMillis: &quality, CriticalFailureMillis: &critical, BenchmarkVersion: current.TaskEvaluation.BenchmarkVersion, ArtifactID: current.TaskEvaluation.ArtifactID, ObservedAtUnixMillis: current.TaskEvaluation.ObservedAtUnixMillis}
|
||||
}
|
||||
|
||||
func stableID(kind string, parts ...string) string {
|
||||
|
||||
@@ -102,3 +102,20 @@ func TestSelectCognitiveBindingUsesObjectiveAndCost(t *testing.T) {
|
||||
t.Fatalf("economy selection=%+v err=%v", selected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCognitiveBindingUsesMediumWhenFastProviderIsUnavailable(t *testing.T) {
|
||||
quality := int64(800)
|
||||
critical := int64(0)
|
||||
candidate := func(id string, health capability.HealthState) capability.ResolvedCognitiveBinding {
|
||||
return capability.ResolvedCognitiveBinding{
|
||||
Binding: capability.CognitiveBinding{ID: capability.CapabilityBindingID(id), Revision: 1, CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", ProviderInstanceID: capability.ProviderInstanceID(id), ProviderRevision: 1, ModelDescriptorID: capability.ModelDescriptorID(id), ModelRevision: 1, DataPolicy: capability.DataPolicyLocalOnly, Qualification: capability.QualificationValidated, Activation: capability.ActivationActive, Health: health, LimitPolicyRef: "limits"},
|
||||
Provider: capability.ProviderInstance{ID: capability.ProviderInstanceID(id), Revision: 1, Kind: "openai-compatible-chat-v1", EndpointRef: capability.EndpointBindingRef(id), Enabled: true, Qualification: capability.QualificationValidated, Health: health, ObservedAtUnixMillis: 1},
|
||||
Model: capability.ModelDescriptor{ID: capability.ModelDescriptorID(id), Revision: 1, ProviderInstanceID: capability.ProviderInstanceID(id), ProviderModelID: capability.ProviderModelID(id), MaxContextBytes: 10000, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, Qualification: capability.QualificationValidated, Health: health, ObservedAtUnixMillis: 1},
|
||||
Evaluation: &capability.EvaluatedCapability{ID: capability.EvaluationID("eval-" + id), Result: capability.EvaluationSupported, QualityScoreMillis: &quality, CriticalFailureMillis: &critical},
|
||||
}
|
||||
}
|
||||
selected, err := SelectCognitiveBinding(capability.CognitiveRequirement{CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", DataPolicy: capability.DataPolicyLocalOnly, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, MaxOutputBytes: 10, Objective: capability.SelectionBalanced}, []capability.ResolvedCognitiveBinding{candidate("cpu-fast", capability.HealthUnavailable), candidate("local-medium", capability.HealthAvailable)})
|
||||
if err != nil || selected.Binding.ID != "local-medium" {
|
||||
t.Fatalf("medium fallback selection=%+v err=%v", selected, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Repository interface {
|
||||
SaveCognitiveBinding(context.Context, capability.CognitiveBinding) error
|
||||
LoadCognitiveCandidates(context.Context, capability.CapabilityID, identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error)
|
||||
LoadCognitiveCandidatesForProfile(context.Context, capability.CapabilityID, identity.PrincipalID, capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error)
|
||||
LoadCognitiveCandidatesForEvaluation(context.Context, capability.CapabilityID, identity.PrincipalID, capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error)
|
||||
LoadCognitiveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error)
|
||||
SaveCognitiveCanaryPromotion(context.Context, capability.CognitiveCanaryPromotion) error
|
||||
SaveCognitiveActivationPromotion(context.Context, capability.CognitiveActivationPromotion) error
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/epistemics"
|
||||
|
||||
func AssessSourceReliability(sourceRef, domainRef string, freshness epistemics.FreshnessState, historyScore, priorScore int64, evidence []string, at int64) epistemics.SourceReliabilityAssessment {
|
||||
score := (historyScore + priorScore) / 2
|
||||
state := epistemics.ReliabilityCurrent
|
||||
if freshness == epistemics.FreshnessUnknown {
|
||||
state = epistemics.ReliabilityUnknown
|
||||
} else if freshness == epistemics.FreshnessStale {
|
||||
state = epistemics.ReliabilityAging
|
||||
}
|
||||
return epistemics.SourceReliabilityAssessment{SourceRef: sourceRef, DomainRef: domainRef, Freshness: freshness, HistoryScoreMillis: historyScore, ReliabilityScoreMillis: score, State: state, EvidenceRefs: append([]string(nil), evidence...), AssessedAtUnixMillis: at}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSourceReliabilityIncludesDomainFreshnessAndHistory(t *testing.T) {
|
||||
a := domain.AssessSourceReliability("source", "weather", epistemics.FreshnessStale, 900, 700, []string{"history"}, 1)
|
||||
if !a.Valid() || a.State != epistemics.ReliabilityAging || a.ReliabilityScoreMillis != 800 {
|
||||
t.Fatalf("assessment=%+v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const creatorPasswordIterations = 120000
|
||||
|
||||
var ErrInvalidCreatorPassword = errors.New("invalid creator password")
|
||||
|
||||
func HashCreatorPassword(password string) (string, error) {
|
||||
if len(password) < 6 || len(password) > 4096 {
|
||||
return "", ErrInvalidCreatorPassword
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32)
|
||||
return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil
|
||||
}
|
||||
|
||||
func VerifyCreatorPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" {
|
||||
return false
|
||||
}
|
||||
iterations, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iterations < 100000 || iterations > 1000000 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(salt) < 16 {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil || len(expected) != 32 {
|
||||
return false
|
||||
}
|
||||
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte {
|
||||
key := make([]byte, 0, keyLength)
|
||||
for block := 1; len(key) < keyLength; block++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
key = append(key, t...)
|
||||
}
|
||||
return key[:keyLength]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreatorPasswordHashRoundTrip(t *testing.T) {
|
||||
hash, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyCreatorPassword(hash, "a-long-enough-test-password") {
|
||||
t.Fatal("hashed password was not accepted")
|
||||
}
|
||||
if VerifyCreatorPassword(hash, "wrong-password") {
|
||||
t.Fatal("wrong password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) {
|
||||
first, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("password hashes reused a salt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) {
|
||||
if _, err := HashCreatorPassword("short"); err == nil {
|
||||
t.Fatal("short password was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
var ErrInvalidRealm = errors.New("invalid principal data realm")
|
||||
|
||||
func ValidatePrincipalDataRealm(value identity.PrincipalDataRealm) error {
|
||||
if !value.Valid() {
|
||||
return ErrInvalidRealm
|
||||
}
|
||||
if value.Kind == identity.DataRealmPersonal && len(value.MemberPrincipalIDs) != 0 {
|
||||
return ErrInvalidRealm
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CanReadRealm(value identity.PrincipalDataRealm, principalID identity.PrincipalID) bool {
|
||||
if ValidatePrincipalDataRealm(value) != nil || principalID == "" {
|
||||
return false
|
||||
}
|
||||
if value.Kind == identity.DataRealmPublic {
|
||||
return true
|
||||
}
|
||||
if principalID == value.OwnerPrincipalID {
|
||||
return true
|
||||
}
|
||||
for _, member := range value.MemberPrincipalIDs {
|
||||
if member == principalID {
|
||||
return value.Kind == identity.DataRealmShared && value.Shared
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/modules/identity/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealmSeparatesOwnerSharedAndPublicAccess(t *testing.T) {
|
||||
r := identity.PrincipalDataRealm{ID: "realm", OwnerPrincipalID: "owner", Kind: identity.DataRealmShared, Shared: true, MemberPrincipalIDs: []identity.PrincipalID{"member"}, Revision: 1, RecordedAtUnixMillis: 1}
|
||||
if !domain.CanReadRealm(r, "owner") || !domain.CanReadRealm(r, "member") || domain.CanReadRealm(r, "guest") {
|
||||
t.Fatalf("realm access leaked: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
var ErrInvalidCreatorSession = errors.New("invalid creator session")
|
||||
|
||||
func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
|
||||
if len(secret) < 32 || ttl <= 0 {
|
||||
return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl)
|
||||
}
|
||||
|
||||
func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
|
||||
if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 {
|
||||
return "", identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()}
|
||||
session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()}
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", identity.CreatorSession{}, err
|
||||
@@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
|
||||
return identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix {
|
||||
if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix {
|
||||
return identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
return session, nil
|
||||
|
||||
@@ -278,6 +278,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
}
|
||||
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
|
||||
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
|
||||
confirmationWords := []string{"хорошо", "ладно", "окей", "ок", "понял", "понятно"}
|
||||
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
|
||||
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь", "вывед"}
|
||||
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
|
||||
@@ -292,6 +293,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
if containsStem(token, conversationWords...) {
|
||||
scores[interaction.SpeechActConversational] += 3
|
||||
}
|
||||
if containsExactToken(token, confirmationWords...) {
|
||||
scores[interaction.SpeechActConfirmation] += 3
|
||||
}
|
||||
if containsStem(token, investigateStems...) {
|
||||
scores[interaction.SpeechActRequestToInvestigate] += 3
|
||||
scores[interaction.SpeechActQuestion]++
|
||||
@@ -616,6 +620,9 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
|
||||
if !inquiry {
|
||||
return false
|
||||
}
|
||||
if focusIntrospectionQuestion(tokens) {
|
||||
return false
|
||||
}
|
||||
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
||||
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
||||
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
|
||||
@@ -710,7 +717,7 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
if cognitiveSubject && inventoryRequest && inventoryCue {
|
||||
return interaction.InformationRequestCognitiveModelInventory
|
||||
}
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "доступн", "инструмент", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
||||
if inquiry && capabilitySubject {
|
||||
if containsStemInTokens(tokens, "без", "внешн", "локальн") && containsStemInTokens(tokens, "модел", "ии", "ai") {
|
||||
return interaction.InformationRequestLocalCapabilityInventory
|
||||
@@ -727,6 +734,13 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
return ""
|
||||
}
|
||||
|
||||
func focusIntrospectionQuestion(tokens []string) bool {
|
||||
if containsStemInTokens(tokens, "фокус", "focus", "контекст") && (containsStemInTokens(tokens, "сейчас", "текущ", "работ", "контекст") || containsMeaningfulInterrogative(tokens)) {
|
||||
return true
|
||||
}
|
||||
return containsStemInTokens(tokens, "над", "делаем", "работаем") && containsStemInTokens(tokens, "сейчас", "текущ", "мы")
|
||||
}
|
||||
|
||||
func LocationContextReply(message string) string {
|
||||
if location := personalCurrentLocation(message); location != "" {
|
||||
return "Запомнил: сейчас вы в " + location + "."
|
||||
@@ -800,6 +814,15 @@ func containsStem(token string, stems ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func containsExactToken(token string, words ...string) bool {
|
||||
for _, word := range words {
|
||||
if token == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsStemInTokens(tokens []string, stems ...string) bool {
|
||||
for _, token := range tokens {
|
||||
if containsStem(token, stems...) {
|
||||
|
||||
@@ -142,6 +142,7 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
|
||||
{"почему ты не смог найти ответ?", interaction.InformationRequestResearchFailureExplanation},
|
||||
{"что ты можешь сделать без внешних моделей?", interaction.InformationRequestLocalCapabilityInventory},
|
||||
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
||||
{"что доступно в этом чате?", interaction.InformationRequestCapabilityInventory},
|
||||
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
||||
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
||||
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
|
||||
@@ -313,6 +314,18 @@ func TestRememberedPreferenceIsNotRenderedAsIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableAcknowledgementsDoNotBecomeTravelRequests(t *testing.T) {
|
||||
for _, message := range []string{"Хорошо", "Ладно", "Понял"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||||
if candidate.PrimarySpeechAct != interaction.SpeechActConfirmation {
|
||||
t.Fatalf("%q: expected confirmation, got %s", message, candidate.PrimarySpeechAct)
|
||||
}
|
||||
if reply, stable := StableConversationalReply(message, candidate.PrimarySpeechAct); !stable || reply != "Хорошо." {
|
||||
t.Fatalf("%q: stable reply = %q, %t", message, reply, stable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
|
||||
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
|
||||
|
||||
@@ -11,6 +11,23 @@ type ContextProjection struct {
|
||||
// BuildGen2Context is a read-side projection over canonical utterance history.
|
||||
// PersonalMemory remains a compatibility fallback and is never written here.
|
||||
func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
|
||||
return BuildScopedGen2Context(history, "")
|
||||
}
|
||||
|
||||
// BuildScopedGen2Context is the Gen2 retrieval boundary for household
|
||||
// context. The caller supplies already-authorized history; this defensive
|
||||
// filter prevents accidental cross-principal projection when a mixed source
|
||||
// is passed during degraded operation.
|
||||
func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection {
|
||||
if principalID != "" {
|
||||
scoped := make([]interaction.HumanUtterance, 0, len(history))
|
||||
for _, item := range history {
|
||||
if item.PrincipalID == "" || string(item.PrincipalID) == principalID {
|
||||
scoped = append(scoped, item)
|
||||
}
|
||||
}
|
||||
history = scoped
|
||||
}
|
||||
text := CompilePersonalMemory(history).ContextText()
|
||||
if text != "" {
|
||||
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
|
||||
|
||||
@@ -3,6 +3,7 @@ package domain_test
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -16,3 +17,22 @@ func TestGen2ContextUsesRetrievalProjectionAndKeepsV1Fallback(t *testing.T) {
|
||||
t.Fatalf("fallback missing: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
|
||||
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||
{ID: "mine", PrincipalID: "principal-1", Content: "Я сейчас в Москве", ReceivedAtUnixMillis: 2},
|
||||
{ID: "other", PrincipalID: "principal-2", Content: "Я сейчас в Казани", ReceivedAtUnixMillis: 3},
|
||||
}, "principal-1")
|
||||
if got.Text == "" || strings.Contains(got.Text, "Казани") || !strings.Contains(got.Text, "Москве") {
|
||||
t.Fatalf("scoped context=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
|
||||
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
|
||||
}, "principal-1")
|
||||
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
|
||||
t.Fatalf("remembered assertion was not projected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type PersonalMemory struct {
|
||||
|
||||
type PersonalMemoryFact struct {
|
||||
Kind string
|
||||
Subject string
|
||||
Value string
|
||||
ConfidenceMillis int64
|
||||
SourceUtteranceID interaction.HumanUtteranceID
|
||||
@@ -26,6 +27,12 @@ type PersonalMemoryFact struct {
|
||||
|
||||
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
|
||||
|
||||
// personalMemoryMaxValueRunes keeps the compatibility projection bounded.
|
||||
// Canonical utterances remain complete in storage; only the rebuildable
|
||||
// cognitive context is capped so old long-form instructions cannot become a
|
||||
// provider prompt or crowd out a current household fact.
|
||||
const personalMemoryMaxValueRunes = 512
|
||||
|
||||
// CompilePersonalMemory turns direct, first-person human statements into a
|
||||
// small typed context. It deliberately preserves preferences as statements
|
||||
// rather than pretending that an arbitrary preference is a verified fact.
|
||||
@@ -48,6 +55,7 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
|
||||
if preference := personalPreference(utterance.Content); preference != "" {
|
||||
preferences = append(preferences, PersonalMemoryFact{
|
||||
Kind: "PREFERENCE",
|
||||
Subject: preferenceSubject(preference),
|
||||
Value: preference,
|
||||
ConfidenceMillis: 900,
|
||||
SourceUtteranceID: utterance.ID,
|
||||
@@ -69,6 +77,21 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
|
||||
return result
|
||||
}
|
||||
|
||||
// LatestPreference returns the current value for a semantic subject while
|
||||
// retaining older facts for historical evidence.
|
||||
func (m PersonalMemory) LatestPreference(subject string) (PersonalMemoryFact, bool) {
|
||||
var latest PersonalMemoryFact
|
||||
for _, fact := range m.Facts {
|
||||
if fact.Kind != "PREFERENCE" || fact.Subject != subject {
|
||||
continue
|
||||
}
|
||||
if latest.Kind == "" || fact.ObservedAtUnixMillis >= latest.ObservedAtUnixMillis {
|
||||
latest = fact
|
||||
}
|
||||
}
|
||||
return latest, latest.Kind != ""
|
||||
}
|
||||
|
||||
func (m PersonalMemory) CurrentLocation() string {
|
||||
for _, fact := range m.Facts {
|
||||
if fact.Kind == personalMemoryCurrentLocation {
|
||||
@@ -97,6 +120,47 @@ func (m PersonalMemory) ContextText() string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// RecallPersonalPreference answers the narrow, read-only memory question from
|
||||
// the rebuildable projection. It is intentionally conservative: only an
|
||||
// explicit question about the remembered test color is handled here; other
|
||||
// questions continue through the normal cognitive route.
|
||||
func RecallPersonalPreference(history []interaction.HumanUtterance, question string) string {
|
||||
lowerQuestion := strings.ToLower(question)
|
||||
if !strings.Contains(lowerQuestion, "цвет") || (!strings.Contains(lowerQuestion, "какой") && !strings.Contains(lowerQuestion, "назвал")) {
|
||||
return ""
|
||||
}
|
||||
memory := CompilePersonalMemory(history)
|
||||
for _, fact := range memory.Facts {
|
||||
if fact.Kind != "PREFERENCE" || fact.Subject != "тестовый цвет" {
|
||||
continue
|
||||
}
|
||||
current, ok := memory.LatestPreference(fact.Subject)
|
||||
if !ok || current.SourceUtteranceID != fact.SourceUtteranceID {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(current.Value)
|
||||
if separator := strings.Index(value, "—"); separator >= 0 {
|
||||
value = strings.TrimSpace(value[separator+len("—"):])
|
||||
}
|
||||
if value != "" {
|
||||
return "Вы называли тестовый цвет: " + value + "."
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func preferenceSubject(preference string) string {
|
||||
if separator := strings.Index(preference, "—"); separator >= 0 {
|
||||
preference = preference[:separator]
|
||||
}
|
||||
subject := strings.TrimSpace(preference)
|
||||
// Operational markers belong to the preference value, not a new subject.
|
||||
if strings.HasPrefix(strings.ToLower(subject), "тестовый цвет") {
|
||||
return "тестовый цвет"
|
||||
}
|
||||
return subject
|
||||
}
|
||||
|
||||
func personalCurrentLocation(message string) string {
|
||||
tokens := semanticTokens(message)
|
||||
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
|
||||
@@ -119,9 +183,35 @@ func personalPreference(message string) string {
|
||||
}
|
||||
value := strings.TrimSpace(lower[index+len(cue):])
|
||||
value = strings.Trim(value, " .,!?:;—-")
|
||||
if len([]rune(value)) >= 2 {
|
||||
if len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
|
||||
return value
|
||||
}
|
||||
}
|
||||
// Explicit remembered assertions are still human-provided context, not
|
||||
// verified facts. Project the compact key/value statement without storing a
|
||||
// second copy outside the canonical utterance history.
|
||||
if index := strings.Index(lower, "запомни"); index >= 0 {
|
||||
remembered := strings.TrimSpace(lower[index+len("запомни"):])
|
||||
remembered = strings.TrimLeft(remembered, " :—-")
|
||||
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
|
||||
if !strings.HasPrefix(remembered, prefix) {
|
||||
continue
|
||||
}
|
||||
statement := strings.TrimSpace(remembered[len(prefix):])
|
||||
separator := strings.IndexAny(statement, "—-:")
|
||||
if separator <= 0 || separator >= len(statement)-1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(statement[:separator])
|
||||
separatorWidth := 1
|
||||
if strings.HasPrefix(statement[separator:], "—") {
|
||||
separatorWidth = len("—")
|
||||
}
|
||||
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
|
||||
if key != "" && len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
|
||||
return key + " — " + value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -28,3 +28,51 @@ func TestCompilePersonalMemoryDoesNotTreatThirdPartyStatementAsPersonalFact(t *t
|
||||
t.Fatalf("ordinary factual question became personal memory: %+v", memory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryBoundsLongRememberedValues(t *testing.T) {
|
||||
longValue := strings.Repeat("x", 513)
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{{
|
||||
Content: "запомни: мой тестовый цвет — " + longValue,
|
||||
ReceivedAtUnixMillis: 100,
|
||||
}})
|
||||
if memory.ContextText() != "" {
|
||||
t.Fatalf("long remembered value leaked into cognitive context: %q", memory.ContextText())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallPersonalPreferenceUsesProjectedAssertion(t *testing.T) {
|
||||
answer := RecallPersonalPreference([]interaction.HumanUtterance{{
|
||||
Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914",
|
||||
}}, "Какой тестовый цвет я назвал?")
|
||||
if answer != "Вы называли тестовый цвет: acceptance-m5-синий-914." {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryRanksCurrentSubjectWithoutDroppingHistory(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{
|
||||
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
|
||||
{ID: "new", Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914", ReceivedAtUnixMillis: 200},
|
||||
})
|
||||
current, ok := memory.LatestPreference("тестовый цвет")
|
||||
if !ok || current.Value != "тестовый цвет — acceptance-m5-синий-914" {
|
||||
t.Fatalf("current=%+v ok=%t", current, ok)
|
||||
}
|
||||
if len(memory.Facts) != 2 {
|
||||
t.Fatalf("historical evidence was dropped: %+v", memory.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryNormalizesOperationalSubjectMarker(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{
|
||||
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
|
||||
{ID: "new", Content: "запомни: мой тестовый цвет M5 — лазурный", ReceivedAtUnixMillis: 200},
|
||||
})
|
||||
current, ok := memory.LatestPreference("тестовый цвет")
|
||||
if !ok || current.SourceUtteranceID != "new" || !strings.Contains(current.Value, "лазурный") {
|
||||
t.Fatalf("expected newest marked preference, got %+v, ok=%t", current, ok)
|
||||
}
|
||||
if len(memory.Facts) != 2 {
|
||||
t.Fatalf("expected historical fact retention, got %d facts", len(memory.Facts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,12 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
|
||||
result.Relations = append(result.Relations, relations...)
|
||||
}
|
||||
result.Reply = semanticReply(utterance.Content, result, previousFocus)
|
||||
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) && previousFocus != nil {
|
||||
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) {
|
||||
result.NeedsCognition = false
|
||||
result.Interpretation.NeedsCognition = false
|
||||
result.CapabilityHints = nil
|
||||
selectedCandidate.CapabilityHints = nil
|
||||
result.Candidates[0] = selectedCandidate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -343,8 +346,10 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
|
||||
case interaction.SpeechActRejection:
|
||||
return "Понял."
|
||||
case interaction.SpeechActQuestion:
|
||||
lower := strings.ToLower(message)
|
||||
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
|
||||
if focusIntrospectionQuestion(semanticTokens(message)) {
|
||||
if previousFocus == nil {
|
||||
return "Сейчас активный Focus не выбран."
|
||||
}
|
||||
return "Сейчас в фокусе: " + previousFocus.ProjectDisplayName + "."
|
||||
}
|
||||
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
|
||||
@@ -385,8 +390,7 @@ func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
|
||||
if act != interaction.SpeechActQuestion {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")
|
||||
return focusIntrospectionQuestion(semanticTokens(message))
|
||||
}
|
||||
|
||||
func hasCorrectionMeaning(candidate interaction.SemanticCandidate, utterance interaction.HumanUtterance) bool {
|
||||
|
||||
@@ -293,6 +293,13 @@ func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceEnglishFocusIntrospectionStaysLocal(t *testing.T) {
|
||||
result := interpretForTest(t, "Какой сейчас Focus разговора?", activeFocus("assistant", "Assistant"), "")
|
||||
if result.Reply != "Сейчас в фокусе: Assistant." || result.NeedsCognition || len(result.CapabilityHints) != 0 {
|
||||
t.Fatalf("focus introspection escaped local route: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
|
||||
result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "")
|
||||
if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package domain
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/interaction"
|
||||
|
||||
// AttributeSpeaker is an observational hint only. It deliberately cannot
|
||||
// manufacture an authenticated principal binding from voice recognition.
|
||||
func AttributeSpeaker(sourceRef, principalCandidate, observationRef string, confidence int64) interaction.SpeakerAttribution {
|
||||
if sourceRef == "" || observationRef == "" || confidence < 0 || confidence > 1000 {
|
||||
return interaction.SpeakerAttribution{}
|
||||
}
|
||||
return interaction.SpeakerAttribution{SourceRef: sourceRef, PrincipalCandidate: principalCandidate, ConfidenceMillis: confidence, ObservationRef: observationRef, Authenticated: false}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeakerRecognitionRemainsObservationNotAuthentication(t *testing.T) {
|
||||
a := domain.AttributeSpeaker("mic-1", "principal-member", "observation-1", 700)
|
||||
if a.Authenticated || a.PrincipalCandidate == "" || a.ObservationRef == "" {
|
||||
t.Fatalf("attribution=%+v", a)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DurableStore is supervisor-owned external control-plane state. It is not a
|
||||
@@ -63,12 +64,17 @@ func (s *DurableStore) Load() (Lifeboat, error) {
|
||||
}
|
||||
|
||||
// PersistentLifeboat reloads before every transition and commits after it.
|
||||
type PersistentLifeboat struct{ store *DurableStore }
|
||||
type PersistentLifeboat struct {
|
||||
store *DurableStore
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewPersistentLifeboat(store *DurableStore) *PersistentLifeboat {
|
||||
return &PersistentLifeboat{store: store}
|
||||
}
|
||||
func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
value, err := p.store.Load()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, err
|
||||
@@ -85,10 +91,40 @@ func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
|
||||
func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) EstablishBaseline(release Release) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaseline(release) })
|
||||
}
|
||||
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
|
||||
}
|
||||
func (p *PersistentLifeboat) Rollback() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Rollback() })
|
||||
}
|
||||
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) { return p.store.Load() }
|
||||
func (p *PersistentLifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.SetTraffic(mode, stableUpstream, candidateUpstream) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) Promote() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool {
|
||||
if value.Current.State != ReleaseHealthy || value.Current.ID == "" {
|
||||
return false
|
||||
}
|
||||
return value.SetTraffic(TrafficCandidate, value.StableUpstream, value.CandidateUpstream)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) RollbackTraffic() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool {
|
||||
if !value.Rollback() {
|
||||
return false
|
||||
}
|
||||
return value.SetTraffic(TrafficStable, value.StableUpstream, value.CandidateUpstream)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.store.Load()
|
||||
}
|
||||
|
||||
@@ -9,22 +9,34 @@ const (
|
||||
ReleaseRolledBack ReleaseState = "ROLLED_BACK"
|
||||
)
|
||||
|
||||
type TrafficMode string
|
||||
|
||||
const (
|
||||
TrafficStable TrafficMode = "STABLE"
|
||||
TrafficCanaryHeader TrafficMode = "CANARY_HEADER"
|
||||
TrafficCandidate TrafficMode = "CANDIDATE"
|
||||
)
|
||||
|
||||
type Release struct {
|
||||
ID string
|
||||
Version string
|
||||
State ReleaseState
|
||||
LastKnownGood bool
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
State ReleaseState `json:"state"`
|
||||
LastKnownGood bool `json:"last_known_good"`
|
||||
}
|
||||
type Observation struct {
|
||||
ReleaseID string
|
||||
Ready bool
|
||||
Reason string
|
||||
ObservedAtUnixMillis int64
|
||||
ReleaseID string `json:"release_id"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
type Lifeboat struct {
|
||||
Current Release
|
||||
LastKnownGood Release
|
||||
Observations []Observation
|
||||
Current Release `json:"current"`
|
||||
LastKnownGood Release `json:"last_known_good"`
|
||||
RollbackTarget Release `json:"rollback_target"`
|
||||
Observations []Observation `json:"observations"`
|
||||
TrafficMode TrafficMode `json:"traffic_mode"`
|
||||
StableUpstream string `json:"stable_upstream"`
|
||||
CandidateUpstream string `json:"candidate_upstream"`
|
||||
}
|
||||
|
||||
func (l *Lifeboat) Start(candidate Release) bool {
|
||||
@@ -32,9 +44,25 @@ func (l *Lifeboat) Start(candidate Release) bool {
|
||||
return false
|
||||
}
|
||||
candidate.State = ReleaseCandidate
|
||||
if l.LastKnownGood.ID != "" {
|
||||
l.RollbackTarget = l.LastKnownGood
|
||||
}
|
||||
l.Current = candidate
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) EstablishBaseline(release Release) bool {
|
||||
if release.ID == "" || release.Version == "" {
|
||||
return false
|
||||
}
|
||||
release.State = ReleaseHealthy
|
||||
release.LastKnownGood = true
|
||||
l.Current = release
|
||||
l.LastKnownGood = release
|
||||
l.RollbackTarget = Release{}
|
||||
l.TrafficMode = TrafficStable
|
||||
return true
|
||||
}
|
||||
func (l *Lifeboat) Record(o Observation) bool {
|
||||
if o.ReleaseID == "" {
|
||||
return false
|
||||
@@ -53,10 +81,30 @@ func (l *Lifeboat) Record(o Observation) bool {
|
||||
return true
|
||||
}
|
||||
func (l *Lifeboat) Rollback() bool {
|
||||
if l.LastKnownGood.ID == "" {
|
||||
target := l.RollbackTarget
|
||||
if target.ID == "" {
|
||||
target = l.LastKnownGood
|
||||
}
|
||||
if target.ID == "" {
|
||||
return false
|
||||
}
|
||||
l.Current = l.LastKnownGood
|
||||
l.Current = target
|
||||
l.Current.State = ReleaseRolledBack
|
||||
l.Current.LastKnownGood = true
|
||||
l.LastKnownGood = l.Current
|
||||
l.RollbackTarget = Release{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) bool {
|
||||
if mode != TrafficStable && mode != TrafficCanaryHeader && mode != TrafficCandidate {
|
||||
return false
|
||||
}
|
||||
if stableUpstream == "" || (mode != TrafficStable && candidateUpstream == "") {
|
||||
return false
|
||||
}
|
||||
l.TrafficMode = mode
|
||||
l.StableUpstream = stableUpstream
|
||||
l.CandidateUpstream = candidateUpstream
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -47,3 +47,38 @@ func TestPersistentLifeboatRestoresLastKnownGoodAfterCandidateFailure(t *testing
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentSupervisorRestoresTrafficModeAfterRestart(t *testing.T) {
|
||||
store, err := supervisor.NewDurableStore(filepath.Join(t.TempDir(), "control", "release-state.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := supervisor.NewPersistentLifeboat(store)
|
||||
if ok, err := first.Start(supervisor.Release{ID: "candidate", Version: "2"}); err != nil || !ok {
|
||||
t.Fatalf("start: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := first.Record(supervisor.Observation{ReleaseID: "candidate", Ready: true, ObservedAtUnixMillis: 1}); err != nil || !ok {
|
||||
t.Fatalf("observe: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := first.SetTraffic(supervisor.TrafficCanaryHeader, "http://stable:8084", "http://candidate:8084"); err != nil || !ok {
|
||||
t.Fatalf("canary: %v %v", ok, err)
|
||||
}
|
||||
second := supervisor.NewPersistentLifeboat(store)
|
||||
state, err := second.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.TrafficMode != supervisor.TrafficCanaryHeader || state.CandidateUpstream != "http://candidate:8084" || state.LastKnownGood.ID != "candidate" {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
if ok, err := second.RollbackTraffic(); err != nil || !ok {
|
||||
t.Fatalf("rollback traffic: %v %v", ok, err)
|
||||
}
|
||||
state, err = second.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.TrafficMode != supervisor.TrafficStable || state.Current.State != supervisor.ReleaseRolledBack {
|
||||
t.Fatalf("rollback state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ $page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get
|
||||
if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' }
|
||||
|
||||
$coreEnvironment = Get-ContainerEnvironment $CoreContainer
|
||||
$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
|
||||
$required = @('CREATOR_USERNAME', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
|
||||
foreach ($name in $required) {
|
||||
if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) {
|
||||
throw "Core runtime is missing $name"
|
||||
}
|
||||
}
|
||||
|
||||
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim()
|
||||
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '/run/secrets/creator_password'") -join "`n").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' }
|
||||
$sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress
|
||||
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest
|
||||
|
||||
@@ -10,7 +10,7 @@ function Invoke-JsonPost([string]$container, [string]$url, [object]$body, [bool]
|
||||
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
|
||||
docker --context $DockerContext cp $local "${container}:$remote" | Out-Null
|
||||
if ($internal) {
|
||||
$response = docker --context $DockerContext exec $container sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$1" "$2"' sh $remote $url
|
||||
$response = docker --context $DockerContext exec -e "POST_FILE=$remote" -e "POST_URL=$url" $container sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$POST_FILE" "$POST_URL"'
|
||||
} else {
|
||||
$response = docker --context $DockerContext exec $container wget -qO- --header "Content-Type: application/json" --post-file $remote $url
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function Expect-InternalPostStatus([string]$container, [string]$url, [object]$bo
|
||||
docker --context $DockerContext cp $local "${container}:$remote" | Out-Null
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$response = (docker --context $DockerContext exec $container sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$1" "$2"' sh $remote $url 2>&1) -join "`n"
|
||||
$response = (docker --context $DockerContext exec -e "POST_FILE=$remote" -e "POST_URL=$url" $container sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$POST_FILE" "$POST_URL"' 2>&1) -join "`n"
|
||||
$ErrorActionPreference = $previous
|
||||
if ($response -notmatch "HTTP/1\.[01] $status") { throw "POST $url did not return $status" }
|
||||
} finally {
|
||||
|
||||
@@ -62,7 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
|
||||
}
|
||||
}
|
||||
|
||||
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
|
||||
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$(cat /run/secrets/creator_bootstrap_token); if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
|
||||
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
|
||||
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token
|
||||
|
||||
|
||||
Reference in New Issue
Block a user