Compare commits
88
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 |
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+217
-48
@@ -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 {
|
||||
@@ -534,7 +582,12 @@ func main() {
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
|
||||
if !creativeUserRequest && locationReply == "" {
|
||||
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})
|
||||
}
|
||||
|
||||
+10
-1
@@ -32,7 +32,7 @@ func main() {
|
||||
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.Start(baseline); startErr != nil || !ok {
|
||||
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 {
|
||||
@@ -53,6 +53,7 @@ func main() {
|
||||
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)
|
||||
@@ -141,6 +142,14 @@ func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -333,7 +335,11 @@ services:
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ for attempt in $(seq 1 30); do
|
||||
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
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"task_file":"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json","task_id":"GEN2-DEPLOY-001","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,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,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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -27,3 +27,12 @@ func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@@ -91,6 +91,10 @@ 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) })
|
||||
}
|
||||
|
||||
@@ -18,24 +18,25 @@ const (
|
||||
)
|
||||
|
||||
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
|
||||
TrafficMode TrafficMode
|
||||
StableUpstream string
|
||||
CandidateUpstream string
|
||||
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 {
|
||||
@@ -43,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
|
||||
@@ -64,11 +81,18 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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