Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee5da8114 | ||
|
|
91f0c9e799 | ||
|
|
cb66a60db2 | ||
|
|
6a4f973e38 | ||
|
|
87ec7efd0f | ||
|
|
51e33e7283 | ||
|
|
e8f35f6393 | ||
|
|
8babf6ae1f | ||
|
|
d0210e5aed | ||
|
|
ebdfaa0769 | ||
|
|
9e0faa7c47 | ||
|
|
7f8a05ccfb | ||
|
|
c94d04acd0 | ||
|
|
bb866cd2ab | ||
|
|
892266a4c8 | ||
|
|
ac531c3ec3 | ||
|
|
4cd50a0937 | ||
|
|
8b751257e8 | ||
|
|
cbbb60e081 | ||
|
|
46e3cfc445 | ||
|
|
5832c49ad8 | ||
|
|
b13d12583b | ||
|
|
9d5473d260 | ||
|
|
50b508b641 | ||
|
|
8400cdd92d | ||
|
|
ec1860d441 | ||
|
|
134ba26e08 | ||
|
|
b82b8b874f | ||
|
|
5c9d4663f8 | ||
|
|
ec9d434650 | ||
|
|
a8a60ce29e | ||
|
|
96cf66a876 | ||
|
|
381b52d06d | ||
|
|
4ceec5a316 | ||
|
|
93a9c1a3bd | ||
|
|
f3ad7d682b | ||
|
|
387de89efe | ||
|
|
346e0acb6b | ||
|
|
3efcbbded8 | ||
|
|
a0b742b1d5 | ||
|
|
86aacea99e | ||
|
|
2a8e4e1b81 | ||
|
|
d64080ef2b | ||
|
|
59d32143bf | ||
|
|
031ba7666c | ||
|
|
93feb9bf2d | ||
|
|
a957172d3c | ||
|
|
3182092d5c | ||
|
|
df8a275eb1 | ||
|
|
8aa5179d47 | ||
|
|
7b62781087 | ||
|
|
5c95916648 | ||
|
|
8caa49f429 | ||
|
|
5b5875d0f4 | ||
|
|
f7ffaee10d | ||
|
|
550738f3ca | ||
|
|
e8b4c4fdab | ||
|
|
39f6fdee7b | ||
|
|
711a734fee | ||
|
|
ad851a98aa | ||
|
|
1e3f29b791 | ||
|
|
62fd265bb7 | ||
|
|
2a52306f90 | ||
|
|
e1c2e5f59c | ||
|
|
49b3165caa | ||
|
|
41b5c57d05 | ||
|
|
31dd628519 | ||
|
|
d0103bbf8e | ||
|
|
29217ca935 | ||
|
|
e9c3df26ae | ||
|
|
70ece6ed5c | ||
|
|
916b93517a | ||
|
|
cf68786d0c | ||
|
|
44ac0203a9 | ||
|
|
8571e3fe59 | ||
|
|
0af6a9b9a5 | ||
|
|
6b9d5be299 | ||
|
|
8b3d6f9176 | ||
|
|
b4ab280950 | ||
|
|
9cb69d1663 | ||
|
|
5387010a15 | ||
|
|
66fd4bfc75 | ||
|
|
8ac4525569 | ||
|
|
40c610945f | ||
|
|
99458a6ced | ||
|
|
25c62e38fc | ||
|
|
5c0c6b3e42 | ||
|
|
788137b4ba | ||
|
|
2ef639eba9 | ||
|
|
d4ed85dec2 | ||
|
|
c1c2110322 | ||
|
|
d83fcb2176 | ||
|
|
041fb1556d | ||
|
|
a3baade40b | ||
|
|
12104210a3 | ||
|
|
40dcc36b06 | ||
|
|
433cb2de2c | ||
|
|
b49ff6cb71 | ||
|
|
4bb6728dfe | ||
|
|
b00130ac15 | ||
|
|
fc356eb0a3 | ||
|
|
0f880896fe | ||
|
|
460a82e020 | ||
|
|
e27ead1a75 | ||
|
|
64bc8bca25 | ||
|
|
67b585af94 | ||
|
|
e47ea7b48e | ||
|
|
75042e090f | ||
|
|
1168c00caf | ||
|
|
6e873ae3af | ||
|
|
b7210dc0ad | ||
|
|
c0704dd28e | ||
|
|
198f6cfe58 | ||
|
|
49608599c4 | ||
|
|
4f6d205a60 | ||
|
|
bcb833f15e | ||
|
|
4f6f2a1f48 | ||
|
|
f709c026d0 | ||
|
|
92ad3516ea | ||
|
|
e1beed0391 | ||
|
|
6b297c7749 | ||
|
|
91b8da2240 | ||
|
|
8924f5a48a | ||
|
|
636fb904f2 | ||
|
|
598a97cd1a | ||
|
|
e9ef635933 | ||
|
|
c516754d63 | ||
|
|
5c965ef69c | ||
|
|
eab7d11bdd | ||
|
|
51a49ba0ef | ||
|
|
60ff22882c | ||
|
|
9896e4f716 | ||
|
|
ff0d0e585b | ||
|
|
13f3165ba0 | ||
|
|
55f2f9affa | ||
|
|
e47e033dda | ||
|
|
2dc5587fd1 | ||
|
|
992c84240b | ||
|
|
cfeb6aeba7 | ||
|
|
f0d45ca9f1 | ||
|
|
e20c9fdfc0 | ||
|
|
cb50a120e5 | ||
|
|
b30eb38eca | ||
|
|
2083096aed | ||
|
|
671ecb4968 | ||
|
|
af9d5809e9 | ||
|
|
990928618d | ||
|
|
9fac8284f5 | ||
|
|
7d9a5edee5 | ||
|
|
32d3f318a5 | ||
|
|
3a563a29ee | ||
|
|
734db5d779 | ||
|
|
36fc8ec8c6 | ||
|
|
b8c001be4c | ||
|
|
eeeba6b7c0 | ||
|
|
fce3ec569c | ||
|
|
a925d787dc | ||
|
|
43556e1603 | ||
|
|
dd00dabf32 | ||
|
|
33365ce7ee | ||
|
|
5017a854ee | ||
|
|
839b35f310 | ||
|
|
96687140ce | ||
|
|
b282038949 | ||
|
|
4de506a2af | ||
|
|
36272b9036 | ||
|
|
a779568622 | ||
|
|
243a11490b | ||
|
|
26207d93ef |
@@ -25,6 +25,13 @@ type OpenAICompatibleLocalAdapter struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// compatibleDefaultContextBytes is a conservative bound used when an
|
||||
// OpenAI-compatible local endpoint omits n_ctx from /v1/models. The adapter
|
||||
// is a chat-completions adapter, so advisory capability is known from the
|
||||
// adapter contract; quality and activation still require governed
|
||||
// qualification/evaluation in the capability registry.
|
||||
const compatibleDefaultContextBytes int64 = 32 << 10
|
||||
|
||||
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
|
||||
}
|
||||
@@ -90,7 +97,10 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
|
||||
stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType)
|
||||
digest := sha256.Sum256([]byte(stableMetadata))
|
||||
contextBytes := item.Meta.Context * 4
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
if contextBytes <= 0 {
|
||||
contextBytes = compatibleDefaultContextBytes
|
||||
}
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
}
|
||||
return models, ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,34 @@ type captureRoundTripper struct {
|
||||
body []byte
|
||||
}
|
||||
|
||||
type modelsMetadataRoundTripper struct{}
|
||||
|
||||
func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"medium-model","owned_by":"llama.cpp","meta":{}}]}`)),
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models, probe := adapter.Discover(context.Background())
|
||||
if probe.Health != capability.HealthAvailable || len(models) != 1 {
|
||||
t.Fatalf("unexpected discovery result: probe=%+v models=%+v", probe, models)
|
||||
}
|
||||
if models[0].MaxContextBytes != compatibleDefaultContextBytes {
|
||||
t.Fatalf("context bound=%d, want %d", models[0].MaxContextBytes, compatibleDefaultContextBytes)
|
||||
}
|
||||
if len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != capability.CapabilityAdvisory {
|
||||
t.Fatalf("capabilities=%v, want advisory", models[0].Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package multimodal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func NormalizeIngress(id string, modality interaction.IngressModality, sourceRef, deviceRef, locationRef, principalID, contentType string, data []byte, response capability.MultimodalResponse, at int64) (interaction.IngressEnvelope, artifact.MediaArtifact) {
|
||||
hash := sha256.Sum256(data)
|
||||
artifactValue := artifact.MediaArtifact{ID: artifact.ArtifactID("artifact:media:" + hex.EncodeToString(hash[:8])), ContentType: contentType, SizeBytes: int64(len(data)), ContentDigest: hex.EncodeToString(hash[:]), SourceRef: sourceRef, RecordedAtUnixMillis: at}
|
||||
if len(data) == 0 {
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, Degraded: true, RecordedAtUnixMillis: at}, artifact.MediaArtifact{}
|
||||
}
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, ArtifactRef: string(artifactValue.ID), ObservationRef: response.EvidenceRef, ProviderRef: response.Provider, ModelRef: response.Model, ModelVersion: response.ModelVersion, ConfidenceMillis: response.ConfidenceMillis, RecordedAtUnixMillis: at}, artifactValue
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package multimodal_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/adapters/multimodal"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIngressNormalizesMediaArtifactAndObservationProvenance(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressImage, "camera:street", "device:cam", "world:street", "principal", "image/png", []byte("image"), capability.MultimodalResponse{Provider: "local", Model: "vision", ModelVersion: "1", EvidenceRef: "observation-1", ConfidenceMillis: 800}, 1)
|
||||
if !e.Valid() || !a.Valid() || e.ArtifactRef == "" || e.ObservationRef != "observation-1" || e.DeviceRef == "" || e.LocationRef == "" {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableIngressDegradesWithoutFabricatingObservation(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressAudio, "mic", "device:mic", "", "principal", "audio/wav", nil, capability.MultimodalResponse{}, 1)
|
||||
if !e.Degraded || e.ObservationRef != "" || a.Valid() {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
@@ -181,10 +181,6 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -437,6 +433,14 @@ func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Con
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidatesForEvaluation(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, evaluationID capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
var profileID capability.TaskEvaluationProfileID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT task_profile_id FROM capability.cognitive_task_evaluations WHERE evaluation_id=$1 AND capability_id=$2`, evaluationID, capabilityID).Scan(&profileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var capabilityID capability.CapabilityID
|
||||
var owner identity.PrincipalID
|
||||
@@ -564,7 +568,12 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
|
||||
// Discovery may advance a head, but must never roll back a governed
|
||||
// CANARY/ACTIVE revision to an older discovered snapshot after restart.
|
||||
// Discovery may advance an inactive head even when its deterministic
|
||||
// revision number is lower, but it must never overwrite a governed canary
|
||||
// or active head during restart/startup refresh.
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT (binding_id) DO UPDATE SET revision=EXCLUDED.revision,updated_at=CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM capability.cognitive_bindings current_binding WHERE current_binding.binding_id=capability.cognitive_binding_revision_heads.binding_id AND current_binding.revision=capability.cognitive_binding_revision_heads.revision AND current_binding.activation IN ('CANARY','ACTIVE'))`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/platform/telemetry"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,11 @@ func (r *EpistemicsRepository) Ping(ctx context.Context) error {
|
||||
// TruthRuntimeStatus is a read-only projection of durable truth bundles.
|
||||
// It does not promote claims, revise beliefs, or mutate epistemic state.
|
||||
func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRuntimeStatus, error) {
|
||||
started := time.Now()
|
||||
outcome := "SUCCESS"
|
||||
defer func() {
|
||||
telemetry.RecordCompatibility("truth-bundles-status-reader", "epistemics-repository", "read", "research-status", true, false, true, outcome, "truth-status", "epistemics", started)
|
||||
}()
|
||||
status := TruthRuntimeStatus{
|
||||
ClaimsByVerification: map[string]int64{},
|
||||
BeliefsByState: map[string]int64{},
|
||||
@@ -43,6 +49,7 @@ func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRun
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM epistemics.truth_bundles`).Scan(&status.BundleCount); err != nil {
|
||||
outcome = "FAILURE"
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
queries := []struct {
|
||||
@@ -56,18 +63,21 @@ func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRun
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
outcome = "FAILURE"
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
outcome = "FAILURE"
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
outcome = "FAILURE"
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
@@ -218,7 +228,13 @@ func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, valu
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error {
|
||||
started := time.Now()
|
||||
outcome := "SUCCESS"
|
||||
defer func() {
|
||||
telemetry.RecordCompatibility("truth-bundles-writer", "epistemics-repository", "write", "persisted-research", true, false, true, outcome, string(value.Claim.ID), "epistemics", started)
|
||||
}()
|
||||
if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 {
|
||||
outcome = "INVALID"
|
||||
return fmt.Errorf("invalid truth bundle")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles (
|
||||
@@ -232,5 +248,8 @@ func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value episte
|
||||
value.Excerpt.ID, value.Excerpt.SnapshotID, value.Excerpt.Text, value.Excerpt.Start, value.Excerpt.End,
|
||||
value.Observation.ID, value.Observation.ObservedAt, value.Claim.ID, value.Claim.Statement, value.Claim.ObservationID, value.Claim.CitationID, value.Claim.Verification,
|
||||
value.Belief.ID, value.Belief.ClaimID, value.Belief.State, value.Citation.ID, value.Citation.ExcerptID, value.Citation.Evidence, value.Citation.Coverage, value.Citation.Conflict, value.Citation.ResolvedAtUnixMillis, recordedAtUnixMillis)
|
||||
if err != nil {
|
||||
outcome = "FAILURE"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,121 @@ var (
|
||||
ErrDevelopmentCandidateConflict = errors.New("development candidate conflict")
|
||||
)
|
||||
|
||||
func (r *WorkRepository) SaveInvestigation(ctx context.Context, value work.Investigation, expectedRevision int64) error {
|
||||
if !value.Valid() || expectedRevision < 0 {
|
||||
return errors.New("invalid investigation revision")
|
||||
}
|
||||
attempts := make([]string, 0, len(value.AttemptRefs))
|
||||
for _, item := range value.AttemptRefs {
|
||||
attempts = append(attempts, string(item))
|
||||
}
|
||||
hypotheses := make([]string, 0, len(value.HypothesisRefs))
|
||||
for _, item := range value.HypothesisRefs {
|
||||
hypotheses = append(hypotheses, string(item))
|
||||
}
|
||||
findings := make([]string, 0, len(value.FindingRefs))
|
||||
for _, item := range value.FindingRefs {
|
||||
findings = append(findings, string(item))
|
||||
}
|
||||
blockers := append([]string(nil), value.Blockers...)
|
||||
checkpoints := make([]string, 0, len(value.CheckpointRefs))
|
||||
for _, item := range value.CheckpointRefs {
|
||||
checkpoints = append(checkpoints, string(item))
|
||||
}
|
||||
if expectedRevision == 0 {
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO work.long_running_work(investigation_id,objective,hypothesis_refs,attempt_refs,finding_refs,blockers,checkpoint_refs,next_step,state,revision,time_budget_millis,cost_budget_micros,max_attempts,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(investigation_id) DO NOTHING`, value.ID, value.Objective, hypotheses, attempts, findings, blockers, checkpoints, value.NextStep, value.State, value.Revision, value.TimeBudgetMillis, value.CostBudgetMicros, value.MaxAttempts, value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return errors.New("investigation already exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result, err := r.pool.Exec(ctx, `UPDATE work.long_running_work SET objective=$2,hypothesis_refs=$3,attempt_refs=$4,finding_refs=$5,blockers=$6,checkpoint_refs=$7,next_step=$8,state=$9,revision=$10,time_budget_millis=$11,cost_budget_micros=$12,max_attempts=$13,recorded_at_unix_millis=$14 WHERE investigation_id=$1 AND revision=$15`, value.ID, value.Objective, hypotheses, attempts, findings, blockers, checkpoints, value.NextStep, value.State, value.Revision, value.TimeBudgetMillis, value.CostBudgetMicros, value.MaxAttempts, value.RecordedAtUnixMillis, expectedRevision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return errors.New("investigation revision conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadInvestigation(ctx context.Context, id work.LongRunningWorkID) (work.Investigation, error) {
|
||||
var value work.Investigation
|
||||
var hypotheses, attempts, findings, blockers, checkpoints []string
|
||||
err := r.pool.QueryRow(ctx, `SELECT investigation_id,objective,hypothesis_refs,attempt_refs,finding_refs,blockers,checkpoint_refs,next_step,state,revision,time_budget_millis,cost_budget_micros,max_attempts,recorded_at_unix_millis FROM work.long_running_work WHERE investigation_id=$1`, id).Scan(&value.ID, &value.Objective, &hypotheses, &attempts, &findings, &blockers, &checkpoints, &value.NextStep, &value.State, &value.Revision, &value.TimeBudgetMillis, &value.CostBudgetMicros, &value.MaxAttempts, &value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.Investigation{}, fmt.Errorf("load investigation: %w", err)
|
||||
}
|
||||
for _, item := range hypotheses {
|
||||
value.HypothesisRefs = append(value.HypothesisRefs, work.EvidenceRef(item))
|
||||
}
|
||||
for _, item := range attempts {
|
||||
value.AttemptRefs = append(value.AttemptRefs, work.AgentRunID(item))
|
||||
}
|
||||
for _, item := range findings {
|
||||
value.FindingRefs = append(value.FindingRefs, work.EvidenceRef(item))
|
||||
}
|
||||
value.Blockers = append(value.Blockers, blockers...)
|
||||
for _, item := range checkpoints {
|
||||
value.CheckpointRefs = append(value.CheckpointRefs, work.AgentCheckpointID(item))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SaveInvestigationCheckpoint atomically records the checkpoint and marks its
|
||||
// attempts verified. The unique key makes retries idempotent.
|
||||
func (r *WorkRepository) SaveInvestigationCheckpoint(ctx context.Context, checkpoint work.InvestigationCheckpoint) error {
|
||||
if !checkpoint.Valid() {
|
||||
return errors.New("invalid investigation checkpoint")
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.investigation_checkpoints(checkpoint_id,investigation_id,completed_attempt_refs,completed_finding_refs,next_step,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(checkpoint_id) DO NOTHING`, checkpoint.ID, checkpoint.InvestigationID, stringIDs(checkpoint.CompletedAttemptRefs), stringEvidence(checkpoint.CompletedFindingRefs), checkpoint.NextStep, checkpoint.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attempt := range checkpoint.CompletedAttemptRefs {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.investigation_attempts(investigation_id,attempt_ref,verified_at_unix_millis) VALUES($1,$2,$3) ON CONFLICT(investigation_id,attempt_ref) DO NOTHING`, checkpoint.InvestigationID, attempt, checkpoint.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) UnverifiedInvestigationAttempts(ctx context.Context, id work.LongRunningWorkID, candidates []work.AgentRunID) ([]work.AgentRunID, error) {
|
||||
result := make([]work.AgentRunID, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM work.investigation_attempts WHERE investigation_id=$1 AND attempt_ref=$2)`, id, candidate).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringIDs(values []work.AgentRunID) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, string(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
func stringEvidence(values []work.EvidenceRef) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, string(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != 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")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
epistemicsdomain "git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
"git.cin.su/m/Assistant/platform/telemetry"
|
||||
)
|
||||
|
||||
const reminderResponseSkillID = capability.SkillProfileID("skill-obvious-reminder-response-v1")
|
||||
@@ -160,6 +161,7 @@ func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastP
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" {
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("fast-path-v1", "core-runtime", "decision", "decline-or-escalate", true, true, false, "OBSERVED", string(input.Utterance.CorrelationID), string(input.Utterance.PrincipalID), input.At)
|
||||
experience, err := epistemicsdomain.FastPathDeclineExperience(
|
||||
epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
@@ -273,9 +275,11 @@ func (r learningRecorder) updateReminderSkill(ctx context.Context, experience ep
|
||||
}
|
||||
profile, err := r.capability.LoadSkillProfile(ctx, reminderResponseSkillID)
|
||||
if err != nil {
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "read", "fast-path-learning", true, false, true, "FAILURE", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
log.Printf("learning skill profile unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "read", "fast-path-learning", true, false, true, "SUCCESS", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
next, err := capabilitydomain.ObserveSkillOutcome(profile, capability.SkillOutcomeObservation{
|
||||
ExperienceRef: capability.SkillEvidenceRef("epistemics:experience:" + string(experience.ID)),
|
||||
Outcome: outcome,
|
||||
@@ -287,9 +291,11 @@ func (r learningRecorder) updateReminderSkill(ctx context.Context, experience ep
|
||||
return
|
||||
}
|
||||
if err := r.capability.SaveSkillProfile(ctx, next); err != nil {
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "write", "fast-path-learning", true, false, true, "FAILURE", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
log.Printf("learning skill profile save unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "write", "fast-path-learning", true, false, true, "SUCCESS", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
}
|
||||
|
||||
func outcomeForHumanResponse(response continuity.HumanResponse) (epistemics.OutcomeClass, int64) {
|
||||
|
||||
+230
-49
@@ -24,6 +24,7 @@ import (
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/platform/telemetry"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"io"
|
||||
@@ -37,6 +38,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 +53,21 @@ 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")
|
||||
}
|
||||
telemetry.RecordCompatibility("non-bootstrap-env-consumers", "core-runtime", "read", "runtime-startup-configuration", true, true, true, "OBSERVED", "startup", "core-runtime", time.Now())
|
||||
telemetry.RecordCompatibility("migration-compatibility-switches", "core-runtime", "observe", "runtime-startup-switch-scan", true, false, false, "NOT_OBSERVED", "startup", "core-runtime", time.Now())
|
||||
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 +83,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 +101,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 +157,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 +196,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 {
|
||||
@@ -505,9 +556,13 @@ func main() {
|
||||
storedFocus, err := repository.ActiveProjectFocus(r.Context(), session.ActorID, session.PrincipalID)
|
||||
if err == nil {
|
||||
previousFocus = &storedFocus
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "SUCCESS", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "FAILURE", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
} else {
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "EMPTY", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
}
|
||||
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
creativeUserRequest := interactiondomain.IsCreativeRequest(utterance.Content)
|
||||
@@ -533,8 +588,16 @@ func main() {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
gen2Context := interactiondomain.BuildGen2Context(conversationHistory)
|
||||
if !creativeUserRequest && locationReply == "" {
|
||||
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
|
||||
if gen2Context.UsedPersonalMemoryFallback {
|
||||
telemetry.RecordCompatibility("personal-memory-fallback", "core-runtime", "read", "gen2-context-empty", true, true, false, "USED", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
}
|
||||
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 +724,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 +755,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 +773,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})
|
||||
}
|
||||
@@ -715,9 +790,11 @@ func main() {
|
||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||
if semantic.ActiveFocus != nil {
|
||||
if err := repository.SaveActiveProjectFocus(r.Context(), *semantic.ActiveFocus); err != nil {
|
||||
telemetry.RecordCompatibility("active-project-focus-write", "core-runtime", "write", "semantic-focus-update", true, false, true, "FAILURE", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
http.Error(w, "save project focus", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("active-project-focus-write", "core-runtime", "write", "semantic-focus-update", true, false, true, "SUCCESS", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus && previousFocus != nil {
|
||||
@@ -733,7 +810,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 +840,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 +883,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 +947,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 +1077,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 +1107,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 +1572,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 +1611,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 +1749,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 +1791,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 +1895,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 +1903,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})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/runtime/supervisor"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
lifeboat *supervisor.PersistentLifeboat
|
||||
stableUpstream string
|
||||
candidate string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
statePath := envOr("SUPERVISOR_STATE_PATH", "/var/lib/supervisor/release-state.json")
|
||||
stable := strings.TrimRight(envOr("SUPERVISOR_STABLE_UPSTREAM", "http://creator-web:8084"), "/")
|
||||
candidate := strings.TrimRight(envOr("SUPERVISOR_CANDIDATE_UPSTREAM", "http://creator-web-candidate:8084"), "/")
|
||||
store, err := supervisor.NewDurableStore(statePath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
lifeboat := supervisor.NewPersistentLifeboat(store)
|
||||
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
|
||||
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
|
||||
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline: %v", startErr))
|
||||
}
|
||||
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline observation: %v", observeErr))
|
||||
}
|
||||
if ok, trafficErr := lifeboat.SetTraffic(supervisor.TrafficStable, stable, candidate); trafficErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize traffic: %v", trafficErr))
|
||||
}
|
||||
} else if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
public := http.NewServeMux()
|
||||
public.HandleFunc("GET /healthz", s.health)
|
||||
public.HandleFunc("GET /readyz", s.ready)
|
||||
public.HandleFunc("/", s.proxy)
|
||||
control := http.NewServeMux()
|
||||
control.HandleFunc("GET /v1/state", s.state)
|
||||
control.HandleFunc("POST /v1/releases/start", s.start)
|
||||
control.HandleFunc("POST /v1/releases/start-set", s.startSet)
|
||||
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
|
||||
control.HandleFunc("POST /v1/releases/baseline-set", s.baselineSet)
|
||||
control.HandleFunc("POST /v1/releases/observe", s.observe)
|
||||
control.HandleFunc("POST /v1/releases/observe-set", s.observeSet)
|
||||
control.HandleFunc("POST /v1/releases/promote", s.promote)
|
||||
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
|
||||
control.HandleFunc("POST /v1/traffic/canary", s.canary)
|
||||
control.HandleFunc("POST /v1/traffic/promote", s.promoteTraffic)
|
||||
control.HandleFunc("POST /v1/traffic/rollback", s.rollbackTraffic)
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(envOr("SUPERVISOR_CONTROL_ADDR", ":8095"), control); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}()
|
||||
if err := http.ListenAndServe(envOr("LISTEN_ADDR", ":8080"), public); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ok"})
|
||||
}
|
||||
|
||||
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil || state.StableUpstream == "" {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if state.StableReleaseSet != nil && !s.checkReleaseSet(r, state.StableReleaseSet) {
|
||||
http.Error(w, "stable release set unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, state.StableUpstream+"/healthz", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil || response.StatusCode/100 != 2 {
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ready"})
|
||||
}
|
||||
|
||||
func (s *server) checkReleaseSet(r *http.Request, releaseSet *supervisor.ReleaseSet) bool {
|
||||
if releaseSet == nil || !releaseSet.Validate() {
|
||||
return false
|
||||
}
|
||||
for _, name := range releaseSet.ComponentNames() {
|
||||
component := releaseSet.Components[name]
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, component.ReadyURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil || response.StatusCode/100 != 2 {
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
return false
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *server) proxy(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
target := state.StableUpstream
|
||||
selected := "stable"
|
||||
if state.TrafficMode == supervisor.TrafficCandidate || (state.TrafficMode == supervisor.TrafficCanaryHeader && strings.EqualFold(r.Header.Get("X-Assistant-Canary"), "true")) {
|
||||
target = state.CandidateUpstream
|
||||
selected = "candidate"
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
http.Error(w, "invalid upstream", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
proxy.ModifyResponse = func(response *http.Response) error {
|
||||
response.Header.Set("X-Assistant-Traffic-Target", selected)
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, _ error) {
|
||||
http.Error(writer, "upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
http.Error(w, "state unavailable", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, state)
|
||||
}
|
||||
func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||
var release supervisor.Release
|
||||
if !decode(w, r, &release) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.Start(release)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) startSet(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseSet supervisor.ReleaseSet
|
||||
if !decode(w, r, &releaseSet) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.StartSet(releaseSet)
|
||||
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) baselineSet(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseSet supervisor.ReleaseSet
|
||||
if !decode(w, r, &releaseSet) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.EstablishBaselineSet(releaseSet)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
|
||||
var observation supervisor.Observation
|
||||
if !decode(w, r, &observation) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.Record(observation)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) observeSet(w http.ResponseWriter, r *http.Request) {
|
||||
var observation supervisor.ReleaseSetObservation
|
||||
if !decode(w, r, &observation) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.RecordSet(observation)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promote(w http.ResponseWriter, _ *http.Request) {
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil {
|
||||
ok, err = s.lifeboat.PromoteSet()
|
||||
} else {
|
||||
ok, err = s.lifeboat.Promote()
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollback(w http.ResponseWriter, _ *http.Request) {
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil || state.RollbackTargetReleaseSet != nil {
|
||||
ok, err = s.lifeboat.RollbackSet()
|
||||
} else {
|
||||
ok, err = s.lifeboat.Rollback()
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) canary(w http.ResponseWriter, _ *http.Request) {
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil {
|
||||
ok, err = s.lifeboat.SetTrafficForSets(supervisor.TrafficCanaryHeader)
|
||||
} else {
|
||||
ok, err = s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Promote()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.RollbackTraffic()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
http.Error(w, "invalid control request", 400)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func respond(w http.ResponseWriter, ok bool, err error) {
|
||||
if err != nil {
|
||||
http.Error(w, "control state unavailable", 503)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "control transition rejected", 409)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||
@@ -5,6 +5,18 @@ import "git.cin.su/m/Assistant/platform/ids"
|
||||
import "errors"
|
||||
|
||||
type ArtifactID ids.ID
|
||||
type MediaArtifact struct {
|
||||
ID ArtifactID
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
ContentDigest string
|
||||
SourceRef string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a MediaArtifact) Valid() bool {
|
||||
return a.ID != "" && a.ContentType != "" && a.SizeBytes > 0 && a.ContentDigest != "" && a.SourceRef != "" && a.RecordedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
type AgentArtifactAttestation struct {
|
||||
ArtifactID ArtifactID
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package capability
|
||||
|
||||
type IntelligencePath string
|
||||
|
||||
const (
|
||||
PathRule IntelligencePath = "RULE"
|
||||
PathReflex IntelligencePath = "REFLEX"
|
||||
PathSkill IntelligencePath = "SKILL"
|
||||
PathSmallLocal IntelligencePath = "SMALL_LOCAL"
|
||||
PathStrongLocal IntelligencePath = "STRONG_LOCAL"
|
||||
PathExternal IntelligencePath = "EXTERNAL"
|
||||
PathResearch IntelligencePath = "RESEARCH"
|
||||
PathTeam IntelligencePath = "TEAM"
|
||||
)
|
||||
|
||||
type StrategyRequest struct {
|
||||
RequiredQuality int64
|
||||
PreferLowLatency bool
|
||||
CostBudgetMicros int64
|
||||
RiskClass string
|
||||
FreshnessRequired bool
|
||||
CostOfError int64
|
||||
}
|
||||
type StrategyDecision struct {
|
||||
Path IntelligencePath
|
||||
Reason string
|
||||
Escalate bool
|
||||
}
|
||||
|
||||
func (r StrategyRequest) Select() StrategyDecision {
|
||||
if r.RiskClass == "HIGH" || r.CostOfError >= 800 {
|
||||
return StrategyDecision{Path: PathStrongLocal, Reason: "high consequence requires deeper reasoning", Escalate: true}
|
||||
}
|
||||
if r.RequiredQuality <= 500 && r.CostBudgetMicros > 0 {
|
||||
return StrategyDecision{Path: PathRule, Reason: "bounded low-risk path"}
|
||||
}
|
||||
if r.PreferLowLatency {
|
||||
return StrategyDecision{Path: PathReflex, Reason: "latency preference with later escalation"}
|
||||
}
|
||||
return StrategyDecision{Path: PathSkill, Reason: "validated reusable strategy"}
|
||||
}
|
||||
@@ -15,11 +15,15 @@ const (
|
||||
)
|
||||
|
||||
type MultimodalResponse struct {
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelVersion string `json:"model_version,omitempty"`
|
||||
ConfidenceMillis int64 `json:"confidence_millis,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package commit
|
||||
|
||||
type ExecutorResult string
|
||||
type AmbiguityState string
|
||||
|
||||
const (
|
||||
ExecutorAccepted ExecutorResult = "ACCEPTED"
|
||||
ExecutorReportedSuccess ExecutorResult = "REPORTED_SUCCESS"
|
||||
ExecutorReportedFailure ExecutorResult = "REPORTED_FAILURE"
|
||||
ExecutorNoResponse ExecutorResult = "NO_RESPONSE"
|
||||
ExecutorAmbiguous ExecutorResult = "AMBIGUOUS"
|
||||
)
|
||||
|
||||
type ExecutionReceipt struct {
|
||||
ID string
|
||||
ActionAttemptRef ActionAttemptID
|
||||
ExecutorImplementationRef string
|
||||
ExternalOperationRef string
|
||||
ExternalRequestID string
|
||||
RequestDigest string
|
||||
ResponseDigest string
|
||||
DispatchAtUnixMillis int64
|
||||
CompletedAtUnixMillis *int64
|
||||
ExecutorResult ExecutorResult
|
||||
Ambiguity AmbiguityState
|
||||
ReconciliationKey string
|
||||
RawArtifactRef string
|
||||
}
|
||||
|
||||
func (r ExecutionReceipt) Valid() bool {
|
||||
return r.ID != "" && r.ActionAttemptRef != "" && r.ExecutorImplementationRef != "" && r.RequestDigest != "" && r.ExecutorResult != "" && r.ReconciliationKey != "" && r.DispatchAtUnixMillis > 0
|
||||
}
|
||||
@@ -81,6 +81,9 @@ type BootstrapConfig struct {
|
||||
RecoveryLocator string
|
||||
}
|
||||
|
||||
// ConversationalConfigurationCommand is an intent, not an assignment. Only
|
||||
// Configuration may turn a SET command into durable state.
|
||||
|
||||
func (d SettingDefinition) Validate() error {
|
||||
if d.ID == "" || d.OwnerNamespace == "" || d.StableKey == "" || !ids.Revision(d.Revision).Valid() || d.ValueType == "" || d.Lifecycle == "" || d.RecordedAtUnixMillis < 1 {
|
||||
return fmt.Errorf("invalid setting definition")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package epistemics
|
||||
|
||||
type WorldOutcomeState string
|
||||
|
||||
const (
|
||||
OutcomeVerifiedSuccess WorldOutcomeState = "VERIFIED_SUCCESS"
|
||||
OutcomeVerifiedPartial WorldOutcomeState = "VERIFIED_PARTIAL"
|
||||
OutcomeVerifiedFailure WorldOutcomeState = "VERIFIED_FAILURE"
|
||||
OutcomeUnverified WorldOutcomeState = "UNVERIFIED"
|
||||
OutcomeUnknownState WorldOutcomeState = "UNKNOWN"
|
||||
OutcomeConflicted WorldOutcomeState = "CONFLICTED"
|
||||
)
|
||||
|
||||
type WorldOutcomeAssessment struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
ActionAttemptRef string
|
||||
ExecutionReceiptRefs []string
|
||||
ExpectedEffectRef string
|
||||
ObservationRefs []ObservationID
|
||||
ClaimRefs []ClaimID
|
||||
State WorldOutcomeState
|
||||
ConfidenceMillis int64
|
||||
EvidenceRefs []EvidenceRef
|
||||
AssessedAt int64
|
||||
}
|
||||
|
||||
func (a WorldOutcomeAssessment) Valid() bool {
|
||||
return a.ID != "" && a.PrincipalID != "" && a.ActionAttemptRef != "" && a.State != "" && a.ConfidenceMillis >= 0 && a.ConfidenceMillis <= 1000 && a.AssessedAt > 0
|
||||
}
|
||||
@@ -17,23 +17,27 @@ type CoverageState string
|
||||
type ConflictState string
|
||||
type EvidenceKind string
|
||||
type ResearchFindingState string
|
||||
type ReliabilityState string
|
||||
|
||||
const (
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||
FindingStale ResearchFindingState = "STALE"
|
||||
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||
FindingStale ResearchFindingState = "STALE"
|
||||
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||
ReliabilityCurrent ReliabilityState = "CURRENT"
|
||||
ReliabilityAging ReliabilityState = "AGING"
|
||||
ReliabilityUnknown ReliabilityState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResearchPlan struct {
|
||||
@@ -52,6 +56,21 @@ type Source struct {
|
||||
Publisher string
|
||||
}
|
||||
|
||||
type SourceReliabilityAssessment struct {
|
||||
SourceRef string
|
||||
DomainRef string
|
||||
Freshness FreshnessState
|
||||
HistoryScoreMillis int64
|
||||
ReliabilityScoreMillis int64
|
||||
State ReliabilityState
|
||||
EvidenceRefs []string
|
||||
AssessedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a SourceReliabilityAssessment) Valid() bool {
|
||||
return a.SourceRef != "" && a.DomainRef != "" && a.Freshness != "" && a.HistoryScoreMillis >= 0 && a.HistoryScoreMillis <= 1000 && a.ReliabilityScoreMillis >= 0 && a.ReliabilityScoreMillis <= 1000 && a.State != "" && a.AssessedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
type SourceSnapshot struct {
|
||||
ID SourceSnapshotID
|
||||
SourceID SourceID
|
||||
|
||||
@@ -5,6 +5,14 @@ type CreatorCredentials struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type CreatorAccount struct {
|
||||
Username string
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
PasswordHash string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
type CreatorSession struct {
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
|
||||
@@ -6,6 +6,30 @@ type ActorID ids.ID
|
||||
type PrincipalID ids.ID
|
||||
type IdentityID ids.ID
|
||||
|
||||
type DataRealmKind string
|
||||
|
||||
const (
|
||||
DataRealmPersonal DataRealmKind = "PERSONAL"
|
||||
DataRealmShared DataRealmKind = "SHARED"
|
||||
DataRealmPublic DataRealmKind = "PUBLIC"
|
||||
)
|
||||
|
||||
// PrincipalDataRealm is an identity-owned access boundary. It does not make
|
||||
// a speaker or World person referent an authenticated principal.
|
||||
type PrincipalDataRealm struct {
|
||||
ID string
|
||||
OwnerPrincipalID PrincipalID
|
||||
Kind DataRealmKind
|
||||
MemberPrincipalIDs []PrincipalID
|
||||
Shared bool
|
||||
Revision int64
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (r PrincipalDataRealm) Valid() bool {
|
||||
return r.ID != "" && r.OwnerPrincipalID != "" && (r.Kind == DataRealmPersonal || r.Kind == DataRealmShared || r.Kind == DataRealmPublic) && r.Revision > 0 && r.RecordedAtUnixMillis > 0 && (r.Kind != DataRealmShared || r.Shared)
|
||||
}
|
||||
|
||||
// PrincipalBinding keeps the operational actor separate from the interest on
|
||||
// whose behalf it acts. A Creator is not exempt from this distinction.
|
||||
type PrincipalBinding struct {
|
||||
|
||||
@@ -25,6 +25,44 @@ type SemanticApplicability string
|
||||
type SemanticCandidateSource string
|
||||
type SemanticCandidateSelectionState string
|
||||
type InformationRequestKind string
|
||||
type ConfigurationCommandKind string
|
||||
type SpeakerAttribution struct {
|
||||
SourceRef string
|
||||
PrincipalCandidate string
|
||||
ConfidenceMillis int64
|
||||
Authenticated bool
|
||||
ObservationRef string
|
||||
}
|
||||
type IngressModality string
|
||||
|
||||
const (
|
||||
IngressText IngressModality = "TEXT"
|
||||
IngressAudio IngressModality = "AUDIO"
|
||||
IngressImage IngressModality = "IMAGE"
|
||||
IngressVideo IngressModality = "VIDEO"
|
||||
IngressDevice IngressModality = "DEVICE"
|
||||
)
|
||||
|
||||
type IngressEnvelope struct {
|
||||
ID string
|
||||
Modality IngressModality
|
||||
SourceRef string
|
||||
DeviceRef string
|
||||
LocationRef string
|
||||
PrincipalID string
|
||||
ArtifactRef string
|
||||
ObservationRef string
|
||||
ProviderRef string
|
||||
ModelRef string
|
||||
ModelVersion string
|
||||
ConfidenceMillis int64
|
||||
Degraded bool
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (e IngressEnvelope) Valid() bool {
|
||||
return e.ID != "" && e.Modality != "" && e.SourceRef != "" && e.ConfidenceMillis >= 0 && e.ConfidenceMillis <= 1000 && e.RecordedAtUnixMillis > 0 && (!e.Degraded || e.ObservationRef == "")
|
||||
}
|
||||
|
||||
const (
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
@@ -75,6 +113,8 @@ const (
|
||||
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||
InformationRequestResearchFailureExplanation InformationRequestKind = "RESEARCH_FAILURE_EXPLANATION"
|
||||
ConfigurationCommandSet ConfigurationCommandKind = "SET"
|
||||
ConfigurationCommandQuery ConfigurationCommandKind = "QUERY"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
@@ -106,6 +146,18 @@ type OriginalHumanIntent struct {
|
||||
RecordedAtUnixMillis int64
|
||||
Text string
|
||||
}
|
||||
|
||||
// ConfigurationCommand is Interaction-owned intent. Configuration remains
|
||||
// the only owner allowed to materialise a SettingAssignment.
|
||||
type ConfigurationCommand struct {
|
||||
Kind ConfigurationCommandKind
|
||||
PrincipalID string
|
||||
DefinitionRef string
|
||||
TypedValue string
|
||||
TemporaryOverride bool
|
||||
SessionRef string
|
||||
SourceRef string
|
||||
}
|
||||
type InteractionRelation struct {
|
||||
ID InteractionRelationID
|
||||
FromUtteranceID HumanUtteranceID
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package self
|
||||
|
||||
type BenchmarkCaseRef string
|
||||
type PersonalBenchmark struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
ApprovedCaseRefs []BenchmarkCaseRef
|
||||
GoalProfile []string
|
||||
PreferenceEvidenceRefs []SemanticReference
|
||||
RecordedAt int64
|
||||
}
|
||||
type BenchmarkEvaluation struct {
|
||||
CaseRef BenchmarkCaseRef
|
||||
Path string
|
||||
QualityMillis int64
|
||||
ReliabilityMillis int64
|
||||
LatencyMillis int64
|
||||
CostMicros int64
|
||||
CorrectionCount int64
|
||||
EvidenceRefs []SemanticReference
|
||||
}
|
||||
|
||||
func (e BenchmarkEvaluation) Valid() bool {
|
||||
return e.CaseRef != "" && len(e.EvidenceRefs) > 0 && e.QualityMillis >= 0 && e.ReliabilityMillis >= 0 && e.CorrectionCount >= 0
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package work
|
||||
|
||||
type ConsequenceAssessment struct {
|
||||
ID string
|
||||
TargetRefs []string
|
||||
Impact int64
|
||||
Reversibility int64
|
||||
Uncertainty int64
|
||||
Scope int64
|
||||
Recoverability int64
|
||||
CostOfError int64
|
||||
ExpectedConsequences []string
|
||||
MitigationOptions []string
|
||||
RequiresDryRun bool
|
||||
RequiresBackup bool
|
||||
RecommendedConfirmation string
|
||||
OwnerAccepted bool
|
||||
EvidenceRefs []EvidenceRef
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a ConsequenceAssessment) Valid() bool {
|
||||
return a.ID != "" && len(a.TargetRefs) > 0 && a.Impact >= 0 && a.Impact <= 1000 && a.Reversibility >= 0 && a.Reversibility <= 1000 && a.Uncertainty >= 0 && a.Uncertainty <= 1000 && a.Recoverability >= 0 && a.Recoverability <= 1000 && a.RecordedAtUnixMillis > 0
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package work
|
||||
|
||||
type EvolutionRunStage string
|
||||
|
||||
const (
|
||||
EvolutionNeedRecorded EvolutionRunStage = "IMPROVEMENT_NEED"
|
||||
EvolutionCandidateBuilt EvolutionRunStage = "CANDIDATE"
|
||||
EvolutionIndependentlyEvaluated EvolutionRunStage = "INDEPENDENT_EVALUATION"
|
||||
EvolutionSandboxed EvolutionRunStage = "SANDBOX_BUILD"
|
||||
EvolutionBenchmarked EvolutionRunStage = "BENCHMARK"
|
||||
EvolutionCanaried EvolutionRunStage = "CANARY"
|
||||
EvolutionPromoted EvolutionRunStage = "PROMOTION"
|
||||
EvolutionRolledBack EvolutionRunStage = "ROLLBACK"
|
||||
)
|
||||
|
||||
type GovernedEvolutionResult struct {
|
||||
NeedRef string
|
||||
CandidateRef string
|
||||
EvaluationRef string
|
||||
Stages []EvolutionRunStage
|
||||
EvidenceRefs []EvidenceRef
|
||||
LearningLedgerOutcomeRef string
|
||||
Promoted bool
|
||||
RollbackRef string
|
||||
}
|
||||
|
||||
// GovernedEvolutionOps are explicit gates. The caller supplies existing
|
||||
// development executor/supervisor operations; this interface grants no new
|
||||
// authority and has no autonomous promotion implementation.
|
||||
type GovernedEvolutionOps struct {
|
||||
CreateCandidate func() (EvolutionCandidate, error)
|
||||
Evaluate func(EvolutionCandidate) (EvolutionEvaluation, error)
|
||||
SandboxBuild func(EvolutionCandidate) (EvidenceRef, error)
|
||||
Benchmark func(EvolutionCandidate) (EvidenceRef, bool, error)
|
||||
Canary func(EvolutionCandidate) (EvidenceRef, bool, error)
|
||||
Promote func(EvolutionCandidate, EvolutionEvaluation) (string, error)
|
||||
Rollback func(EvolutionCandidate) (string, error)
|
||||
LearningLedgerOutcome func(GovernedEvolutionResult) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package work
|
||||
|
||||
type LongRunningWorkID string
|
||||
type LongRunningState string
|
||||
|
||||
const (
|
||||
LongRunningProposed LongRunningState = "PROPOSED"
|
||||
LongRunningRunning LongRunningState = "RUNNING"
|
||||
LongRunningPaused LongRunningState = "PAUSED"
|
||||
LongRunningBlocked LongRunningState = "BLOCKED"
|
||||
LongRunningCompleted LongRunningState = "COMPLETED"
|
||||
LongRunningExhausted LongRunningState = "EXHAUSTED"
|
||||
LongRunningCancelled LongRunningState = "CANCELLED"
|
||||
)
|
||||
|
||||
type Investigation struct {
|
||||
ID LongRunningWorkID
|
||||
Objective string
|
||||
HypothesisRefs []EvidenceRef
|
||||
AttemptRefs []AgentRunID
|
||||
FindingRefs []EvidenceRef
|
||||
Blockers []string
|
||||
CheckpointRefs []AgentCheckpointID
|
||||
NextStep string
|
||||
State LongRunningState
|
||||
Revision int64
|
||||
TimeBudgetMillis int64
|
||||
CostBudgetMicros int64
|
||||
MaxAttempts int
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// InvestigationCheckpoint is the durable boundary between attempts. A
|
||||
// checkpoint is written only after an attempt has been verified, so a worker
|
||||
// restart can resume at NextStep without replaying that attempt.
|
||||
type InvestigationCheckpoint struct {
|
||||
ID AgentCheckpointID
|
||||
InvestigationID LongRunningWorkID
|
||||
CompletedAttemptRefs []AgentRunID
|
||||
CompletedFindingRefs []EvidenceRef
|
||||
NextStep string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (c InvestigationCheckpoint) Valid() bool {
|
||||
return c.ID != "" && c.InvestigationID != "" && c.NextStep != "" && c.RecordedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
func (w Investigation) Valid() bool {
|
||||
return w.ID != "" && w.Objective != "" && w.State != "" && w.Revision > 0 && w.TimeBudgetMillis >= 0 && w.CostBudgetMicros >= 0 && w.MaxAttempts >= 0 && w.RecordedAtUnixMillis > 0
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package work
|
||||
|
||||
type PerspectiveID string
|
||||
type PerspectiveSpec struct {
|
||||
ID PerspectiveID
|
||||
ObjectiveLens string
|
||||
FocusRef string
|
||||
RetrievalProfileRef string
|
||||
SkillRefs []string
|
||||
ToolRefs []string
|
||||
EvaluationCriteria []string
|
||||
CapabilityRequirements []string
|
||||
ExpectedOutputShape string
|
||||
Critic bool
|
||||
}
|
||||
type SpecialistAssignment struct {
|
||||
Perspective PerspectiveSpec
|
||||
SubtaskRef string
|
||||
FocusRef string
|
||||
SkillRefs []string
|
||||
ToolRefs []string
|
||||
EvidenceRefs []EvidenceRef
|
||||
}
|
||||
type TeamDecision struct {
|
||||
UseTeam bool
|
||||
Reason string
|
||||
Assignments []SpecialistAssignment
|
||||
}
|
||||
|
||||
func (p PerspectiveSpec) Valid() bool {
|
||||
return p.ID != "" && p.ObjectiveLens != "" && p.FocusRef != "" && len(p.SkillRefs) > 0 && len(p.ToolRefs) > 0 && len(p.EvaluationCriteria) > 0 && p.ExpectedOutputShape != ""
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package work
|
||||
|
||||
type EvolutionChangeLevel string
|
||||
|
||||
const (
|
||||
EvolutionKnowledge EvolutionChangeLevel = "KNOWLEDGE"
|
||||
EvolutionBehavior EvolutionChangeLevel = "BEHAVIOR"
|
||||
EvolutionCapability EvolutionChangeLevel = "CAPABILITY"
|
||||
EvolutionCore EvolutionChangeLevel = "CORE"
|
||||
)
|
||||
|
||||
type EvolutionCandidate struct {
|
||||
ID string
|
||||
ProposalRef string
|
||||
ChangeLevel EvolutionChangeLevel
|
||||
CreatorIdentity string
|
||||
BaseVersionRef string
|
||||
CandidateVersionRef string
|
||||
MustNotWorsen []string
|
||||
SandboxEvidenceRefs []EvidenceRef
|
||||
BenchmarkEvidenceRefs []EvidenceRef
|
||||
RollbackRef string
|
||||
State string
|
||||
}
|
||||
type EvolutionEvaluation struct {
|
||||
CandidateRef string
|
||||
EvaluatorIdentity string
|
||||
Approved bool
|
||||
EvidenceRefs []EvidenceRef
|
||||
MustNotWorsenPassed bool
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (e EvolutionEvaluation) ValidFor(c EvolutionCandidate) bool {
|
||||
return e.CandidateRef == c.ID && e.EvaluatorIdentity != "" && e.EvaluatorIdentity != c.CreatorIdentity && e.Approved && e.MustNotWorsenPassed && len(e.EvidenceRefs) > 0 && e.RecordedAtUnixMillis > 0
|
||||
}
|
||||
@@ -104,6 +104,16 @@ type ValueTypeSpec struct {
|
||||
type WorldObjectID ids.StableID
|
||||
type KindDefinitionID ids.StableID
|
||||
type WorldAliasID ids.StableID
|
||||
type PersonReferent struct {
|
||||
WorldObjectRef SemanticReference
|
||||
DisplayName string
|
||||
ConfidenceMillis int64
|
||||
}
|
||||
|
||||
func (p PersonReferent) Valid() bool {
|
||||
return p.WorldObjectRef.Valid() && p.DisplayName != "" && p.ConfidenceMillis >= 0 && p.ConfidenceMillis <= 1000
|
||||
}
|
||||
|
||||
type FacetDefinitionID ids.StableID
|
||||
type RelationDefinitionID ids.StableID
|
||||
type ResolutionDecisionID ids.StableID
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
|
||||
-- This is intentionally separate from feature migrations and may be run repeatedly.
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
|
||||
CREATE ROLE world_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
|
||||
CREATE ROLE configuration_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
|
||||
CREATE ROLE self_role LOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
role_name TEXT;
|
||||
can_login BOOLEAN;
|
||||
BEGIN
|
||||
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
|
||||
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
|
||||
IF can_login IS DISTINCT FROM TRUE THEN
|
||||
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
|
||||
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
|
||||
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
|
||||
|
||||
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
expected_owner TEXT;
|
||||
actual_owner TEXT;
|
||||
BEGIN
|
||||
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
|
||||
expected_owner := schema_name || '_role';
|
||||
SELECT pg_get_userbyid(nspowner)
|
||||
INTO actual_owner
|
||||
FROM pg_namespace
|
||||
WHERE nspname = schema_name;
|
||||
IF actual_owner IS DISTINCT FROM expected_owner THEN
|
||||
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
SET ROLE work_role;
|
||||
CREATE TABLE IF NOT EXISTS work.long_running_work (
|
||||
investigation_id TEXT PRIMARY KEY, objective TEXT NOT NULL,
|
||||
hypothesis_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], attempt_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
finding_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], blockers TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
checkpoint_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], next_step TEXT NOT NULL, state TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL, time_budget_millis BIGINT NOT NULL, cost_budget_micros BIGINT NOT NULL,
|
||||
max_attempts INTEGER NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (investigation_id <> ''), CHECK (objective <> ''), CHECK (next_step <> ''), CHECK (revision > 0), CHECK (time_budget_millis >= 0), CHECK (cost_budget_micros >= 0), CHECK (max_attempts >= 0), CHECK (recorded_at_unix_millis > 0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS work.investigation_checkpoints (
|
||||
checkpoint_id TEXT PRIMARY KEY, investigation_id TEXT NOT NULL, completed_attempt_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
completed_finding_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], next_step TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (checkpoint_id <> ''), CHECK (investigation_id <> ''), CHECK (next_step <> ''), CHECK (recorded_at_unix_millis > 0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS work.investigation_attempts (
|
||||
investigation_id TEXT NOT NULL, attempt_ref TEXT NOT NULL, verified_at_unix_millis BIGINT NOT NULL,
|
||||
PRIMARY KEY (investigation_id, attempt_ref), CHECK (investigation_id <> ''), CHECK (attempt_ref <> ''), CHECK (verified_at_unix_millis > 0)
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.principal_data_realms (
|
||||
realm_id TEXT PRIMARY KEY, owner_principal_id TEXT NOT NULL, realm_kind TEXT NOT NULL,
|
||||
member_principal_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revision BIGINT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (realm_id <> ''), CHECK (owner_principal_id <> ''), CHECK (realm_kind IN ('PERSONAL','SHARED','PUBLIC')),
|
||||
CHECK (realm_kind <> 'SHARED' OR shared), CHECK (revision > 0), CHECK (recorded_at_unix_millis > 0)
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.creator_accounts (
|
||||
username TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL DEFAULT 1,
|
||||
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (username <> ''),
|
||||
CHECK (actor_id <> ''),
|
||||
CHECK (principal_id <> ''),
|
||||
CHECK (password_hash <> ''),
|
||||
CHECK (revision > 0),
|
||||
UNIQUE (actor_id, principal_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id);
|
||||
REVOKE ALL ON identity.creator_accounts FROM PUBLIC;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -25,6 +25,16 @@ psql_exec() {
|
||||
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
|
||||
}
|
||||
|
||||
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
|
||||
# before feature migrations run. This keeps role creation out of migrations and
|
||||
# makes existing PostgreSQL volumes converge idempotently as well.
|
||||
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
|
||||
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
|
||||
echo "migration runner: Gen2 bootstrap is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
psql_exec < "$GEN2_BOOTSTRAP"
|
||||
|
||||
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
|
||||
|
||||
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
|
||||
|
||||
+104
-13
@@ -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}
|
||||
@@ -88,6 +83,22 @@ services:
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 20
|
||||
|
||||
core-runtime-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: core-runtime
|
||||
restart: "no"
|
||||
environment:
|
||||
RESEARCH_RUNTIME_URL: http://research-runtime-candidate:8087
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
research-runtime-candidate: { condition: service_started }
|
||||
|
||||
agent-canary:
|
||||
profiles: ["agent-canary"]
|
||||
@@ -182,7 +193,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,12 +212,24 @@ 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:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
- assistant_research_sources:/var/lib/research-sources:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8087/healthz >/dev/null && wget -qO- http://localhost:8087/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
|
||||
research-runtime-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: research-runtime
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
continuity-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
@@ -253,8 +275,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 +287,28 @@ 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-worker-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: cognitive-worker
|
||||
restart: "no"
|
||||
environment:
|
||||
CORE_RUNTIME_URL: http://core-runtime-candidate:8080
|
||||
depends_on:
|
||||
core-runtime-candidate: { condition: service_started }
|
||||
cognitive-lifeboat: { condition: service_healthy }
|
||||
|
||||
cognitive-lifeboat:
|
||||
image: ghcr.io/ggml-org/llama.cpp:server
|
||||
@@ -277,7 +319,7 @@ services:
|
||||
- --port
|
||||
- "8086"
|
||||
- --model
|
||||
- /models/Qwen3-8B-Q4_K_M.gguf
|
||||
- /models/Qwen3.8-27B-UD-Q2_K_XL.gguf
|
||||
- --alias
|
||||
- domovoy-lifeboat
|
||||
- --jinja
|
||||
@@ -299,6 +341,29 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
supervisor:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: supervisor, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
SUPERVISOR_CONTROL_ADDR: ":8095"
|
||||
SUPERVISOR_STATE_PATH: /var/lib/supervisor/release-state.json
|
||||
SUPERVISOR_STABLE_UPSTREAM: http://creator-web:8084
|
||||
SUPERVISOR_CANDIDATE_UPSTREAM: http://creator-web-candidate:8084
|
||||
SUPERVISOR_BASELINE_RELEASE: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports:
|
||||
- "${CREATOR_WEB_PORT:-18080}:8080"
|
||||
- "127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}:8095"
|
||||
depends_on:
|
||||
creator-web: { condition: service_healthy }
|
||||
volumes:
|
||||
- assistant_supervisor_state:/var/lib/supervisor
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
creator-web:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -310,14 +375,39 @@ services:
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||
depends_on:
|
||||
core-runtime: { condition: service_started }
|
||||
continuity-runtime: { condition: service_started }
|
||||
commit-runtime: { condition: service_started }
|
||||
cognitive-worker: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
creator-web-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: creator-web
|
||||
restart: "no"
|
||||
environment:
|
||||
CORE_RUNTIME_URL: http://core-runtime-candidate:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_RUNTIME_URL: http://commit-runtime:8082
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker-candidate:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
depends_on:
|
||||
core-runtime-candidate: { condition: service_started }
|
||||
continuity-runtime: { condition: service_started }
|
||||
commit-runtime: { condition: service_started }
|
||||
cognitive-worker-candidate: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
assistant_pg:
|
||||
assistant_nats:
|
||||
@@ -333,6 +423,7 @@ volumes:
|
||||
external: true
|
||||
assistant_research_sources:
|
||||
name: assistant_research_sources
|
||||
assistant_supervisor_state:
|
||||
|
||||
networks:
|
||||
llm-local:
|
||||
|
||||
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
|
||||
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
||||
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
|
||||
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
|
||||
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
||||
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
|
||||
USER assistant
|
||||
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
||||
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||
SUPERVISOR_CONTROL_URL="${SUPERVISOR_CONTROL_URL:-http://127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}}"
|
||||
REVISION="${1:?candidate revision is required}"
|
||||
HEALTH_ATTEMPTS="${HEALTH_ATTEMPTS:-360}"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
control() { curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL$1" "${@:2}" >/dev/null; }
|
||||
fail_rollback() {
|
||||
control /v1/traffic/rollback || true
|
||||
control /v1/releases/rollback || true
|
||||
echo "production ReleaseSet canary failed; stable ReleaseSet rollback requested" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
services=(creator-web-candidate core-runtime-candidate cognitive-worker-candidate research-runtime-candidate)
|
||||
containers=(assistant-creator-web-candidate-1 assistant-core-runtime-candidate-1 assistant-cognitive-worker-candidate-1 assistant-research-runtime-candidate-1)
|
||||
stable_services=(creator-web core-runtime cognitive-worker research-runtime)
|
||||
|
||||
stable_ids() {
|
||||
local service container id
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="assistant-${service}-1"
|
||||
id=$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null || true)
|
||||
[[ -n "$id" ]] || { echo "stable container missing: $container" >&2; return 1; }
|
||||
printf '%s=%s\n' "$service" "$id"
|
||||
done
|
||||
}
|
||||
|
||||
assert_stable_ids_unchanged() {
|
||||
local after
|
||||
after=$(stable_ids) || fail_rollback
|
||||
[[ "$after" == "$stable_ids_before" ]] || {
|
||||
echo "stable container IDs changed during candidate operation" >&2
|
||||
diff -u <(printf '%s\n' "$stable_ids_before") <(printf '%s\n' "$after") >&2 || true
|
||||
fail_rollback
|
||||
}
|
||||
}
|
||||
|
||||
stable_ids_before=$(stable_ids) || exit 1
|
||||
|
||||
compose --profile assistant-canary build --build-arg "SOURCE_REVISION=$REVISION" "${services[@]}"
|
||||
|
||||
wait_healthy() {
|
||||
local container="$1" status="" ready=""
|
||||
ready=""
|
||||
for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do
|
||||
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' "$container" 2>/dev/null || true)
|
||||
if [[ "$status" == healthy ]]; then ready=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[[ "$ready" == true ]] || fail_rollback
|
||||
}
|
||||
|
||||
compose --profile assistant-canary up -d --no-build --no-deps research-runtime-candidate core-runtime-candidate
|
||||
wait_healthy assistant-research-runtime-candidate-1
|
||||
wait_healthy assistant-core-runtime-candidate-1
|
||||
compose --profile assistant-canary up -d --no-build --no-deps cognitive-worker-candidate
|
||||
wait_healthy assistant-cognitive-worker-candidate-1
|
||||
compose --profile assistant-canary up -d --no-build --no-deps creator-web-candidate
|
||||
wait_healthy assistant-creator-web-candidate-1
|
||||
assert_stable_ids_unchanged
|
||||
|
||||
for container in "${containers[@]}"; do
|
||||
image_revision=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
||||
[[ "$image_revision" == "$REVISION" ]] || fail_rollback
|
||||
done
|
||||
assert_stable_ids_unchanged
|
||||
|
||||
if [[ "${PREPARE_ONLY:-false}" == true ]]; then
|
||||
echo "production ReleaseSet candidate prepared revision=$REVISION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_json=$(cat <<JSON
|
||||
{
|
||||
"id":"candidate-$REVISION",
|
||||
"revision":"$REVISION",
|
||||
"components":{
|
||||
"creator-web":{"name":"creator-web","revision":"$REVISION","upstream":"http://creator-web-candidate:8084","health_url":"http://creator-web-candidate:8084/healthz","ready_url":"http://creator-web-candidate:8084/healthz"},
|
||||
"core-runtime":{"name":"core-runtime","revision":"$REVISION","upstream":"http://core-runtime-candidate:8080","health_url":"http://core-runtime-candidate:8080/healthz","ready_url":"http://core-runtime-candidate:8080/readyz"},
|
||||
"cognitive-worker":{"name":"cognitive-worker","revision":"$REVISION","upstream":"http://cognitive-worker-candidate:8083","health_url":"http://cognitive-worker-candidate:8083/healthz","ready_url":"http://cognitive-worker-candidate:8083/readyz"},
|
||||
"research-runtime":{"name":"research-runtime","revision":"$REVISION","upstream":"http://research-runtime-candidate:8087","health_url":"http://research-runtime-candidate:8087/healthz","ready_url":"http://research-runtime-candidate:8087/readyz"}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
stable_json=$(curl -fsS --max-time 10 "$SUPERVISOR_CONTROL_URL/v1/state")
|
||||
stable_set=$(printf '%s' "$stable_json" | jq -c '.stable_release_set // empty')
|
||||
if [[ -z "$stable_set" ]]; then
|
||||
stable_revision=$(printf '%s' "$stable_json" | jq -r '.current.version // "unknown"')
|
||||
stable_set=$(cat <<JSON
|
||||
{"id":"stable-$stable_revision","revision":"$stable_revision","components":{
|
||||
"creator-web":{"name":"creator-web","revision":"$stable_revision","upstream":"http://creator-web:8084","health_url":"http://creator-web:8084/healthz","ready_url":"http://creator-web:8084/readyz"},
|
||||
"core-runtime":{"name":"core-runtime","revision":"$stable_revision","upstream":"http://core-runtime:8080","health_url":"http://core-runtime:8080/healthz","ready_url":"http://core-runtime:8080/readyz"},
|
||||
"cognitive-worker":{"name":"cognitive-worker","revision":"$stable_revision","upstream":"http://cognitive-worker:8083","health_url":"http://cognitive-worker:8083/healthz","ready_url":"http://cognitive-worker:8083/readyz"},
|
||||
"research-runtime":{"name":"research-runtime","revision":"$stable_revision","upstream":"http://research-runtime:8087","health_url":"http://research-runtime:8087/healthz","ready_url":"http://research-runtime:8087/readyz"}}}
|
||||
JSON
|
||||
)
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/baseline-set" -H 'Content-Type: application/json' --data "$stable_set" >/dev/null || fail_rollback
|
||||
fi
|
||||
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/start-set" -H 'Content-Type: application/json' --data "$release_json" >/dev/null || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
observation=$(printf '{"release_id":"candidate-%s","ready":true,"reason":"all ReleaseSet healthchecks and image revisions passed","observed_at_unix_millis":%s,"components":{"creator-web":{"revision":"%s","health":true,"ready":true},"core-runtime":{"revision":"%s","health":true,"ready":true},"cognitive-worker":{"revision":"%s","health":true,"ready":true},"research-runtime":{"revision":"%s","health":true,"ready":true}}}\n' "$REVISION" "$(date +%s%3N)" "$REVISION" "$REVISION" "$REVISION" "$REVISION")
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/observe-set" -H 'Content-Type: application/json' --data "$observation" >/dev/null || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
control /v1/traffic/canary || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
|
||||
headers=$(mktemp)
|
||||
trap 'rm -f "$headers"' EXIT
|
||||
if ! curl -fsS --max-time 10 -D "$headers" -H 'X-Assistant-Canary: true' "https://assistant.cin.su/login" -o /dev/null; then fail_rollback; fi
|
||||
grep -qi '^X-Assistant-Traffic-Target: candidate' "$headers" || fail_rollback
|
||||
|
||||
control /v1/releases/promote || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
echo "production ReleaseSet canary promoted revision=$REVISION"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
stable_services=(creator-web core-runtime cognitive-worker research-runtime)
|
||||
stable_ids() {
|
||||
local service container id
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="assistant-${service}-1"
|
||||
id=$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null || true)
|
||||
[[ -n "$id" ]] || { echo "stable container missing: $container" >&2; return 1; }
|
||||
printf '%s=%s\n' "$service" "$id"
|
||||
done
|
||||
}
|
||||
|
||||
before=$(stable_ids)
|
||||
compose up -d --no-build --no-deps supervisor
|
||||
after=$(stable_ids)
|
||||
[[ "$before" == "$after" ]] || {
|
||||
echo "stable container IDs changed while updating supervisor" >&2
|
||||
diff -u <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$after"
|
||||
@@ -1 +1 @@
|
||||
{"task_file":"dev/tasks/GEN2-015-add-evidence-backed-self-foundation-and-learning-ledger.json","task_id":"GEN2-015","task_revision":1}
|
||||
{"task_file":"dev/tasks/GEN2-COMPAT-OBS-001-production-compatibility-observation.json","task_id":"GEN2-COMPAT-OBS-001","task_revision":2}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-028",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -16,7 +16,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"docs/**",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-029",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -16,7 +16,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"database/migrations/*.sql",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-030",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -20,7 +20,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"tests/**"
|
||||
|
||||
@@ -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,65 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Добавить bounded redacted production telemetry для оставшихся V1 compatibility paths без изменения их поведения, owners или canonical storage.",
|
||||
"requirement_ids": ["REQ-GEN2-COMPAT-OBS-001-TELEMETRY", "REQ-GEN2-COMPAT-OBS-001-OBSERVATION", "REQ-GEN2-COMPAT-OBS-001-ROLLBACK"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"platform/telemetry/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/research-runtime/**",
|
||||
"modules/interaction/domain/**",
|
||||
"modules/capability/domain/**",
|
||||
"adapters/postgres/**",
|
||||
"docs/**",
|
||||
"tests/**",
|
||||
"deploy/run-production-canary.sh",
|
||||
"deploy/update-supervisor.sh",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-COMPAT-OBS-001-production-compatibility-observation.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["security/**", "go.mod", "go.sum", "architecture/**", ".gitea/workflows/**", "database/**", "adapters/models/**"]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 1,
|
||||
"new_third_party_dependencies": 0,
|
||||
"new_database_owner_roles": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["existing runtime logs and capability usage observation mechanisms"],
|
||||
"may_change": ["telemetry event shape only; no semantic/domain contract change"]
|
||||
},
|
||||
"semantic_owner": "Migration/Governance; telemetry is an operational projection and never a canonical semantic owner.",
|
||||
"canonical_state": ["no change", "V1 compatibility behavior and Gen2 canonical writers remain unchanged"],
|
||||
"state_transitions": ["path invocation -> redacted telemetry event -> bounded aggregation/observation -> retirement classification"],
|
||||
"side_effects_and_privileges": ["stdout/stderr telemetry only; no external mutation, secret access, or user content logging"],
|
||||
"persistence_and_migrations": ["no schema or migration change; telemetry is emitted to existing process logs"],
|
||||
"failure_retry_restart": ["telemetry failure must never change or block the compatibility path", "rollback is code revert to the pre-instrumentation commit"],
|
||||
"assumptions": ["production Supervisor deployment is authorized by this stage", "observation window is bounded and redacted", "no credentials are read"],
|
||||
"acceptance_scenarios": [
|
||||
"COMPAT-OBS-001-SCN-01: each listed path has a typed redacted event shape",
|
||||
"COMPAT-OBS-001-SCN-02: events include invocation, component, read/write, reason, Gen2 availability, fallback/primary, outcome and safe correlation",
|
||||
"COMPAT-OBS-001-SCN-03: no event contains content, secrets, tokens or raw user identifiers",
|
||||
"COMPAT-OBS-001-SCN-04: FAST tests prove instrumentation is non-blocking and path behavior is unchanged",
|
||||
"COMPAT-OBS-001-SCN-05: deployed production smoke produces observable compatibility telemetry",
|
||||
"COMPAT-OBS-001-EXIT: production observation can classify each path ACTIVE_REQUIRED, ACTIVE_FALLBACK, SHADOW_ONLY, UNUSED or UNKNOWN without claiming retirement from absence of evidence"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: targeted go test for changed runtime/domain/adapter packages",
|
||||
"FAST: targeted go vet for changed packages",
|
||||
"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 gen2-integration --",
|
||||
"production smoke: Supervisor candidate deployment and redacted log observation"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"task_id": "GEN2-DB-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Исправить provisioning Gen2 owner roles/schemas до применения migrations 046-050 без ослабления V1 DB isolation.",
|
||||
"requirement_ids": ["REQ-GEN2-DB-001-ROLES", "REQ-GEN2-DB-001-OWNERSHIP", "REQ-GEN2-DB-001-IDEMPOTENCY", "REQ-GEN2-DB-001-RECOVERY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["database bootstrap", "deployment migration runner"],
|
||||
"paths": [
|
||||
"database/bootstrap/002_gen2_roles_and_schemas.sql",
|
||||
"deploy/apply-migrations.sh",
|
||||
"deploy/images/postgres.Dockerfile",
|
||||
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json"
|
||||
],
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"GEN2-028",
|
||||
"production-canary-before-provisioning",
|
||||
"feature-migration-role-creation",
|
||||
"cross-owner-grants",
|
||||
"new canonical owner",
|
||||
"production canary before provisioning"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["V1 database ownership/ACL", "GEN2 canonical owner registry", "GEN2 execution contract"], "may_change": ["Bootstrap provisioning only; feature migration semantics are unchanged."]},
|
||||
"semantic_owner": "Dedicated PostgreSQL bootstrap/migration principal; semantic ownership remains World, Configuration, SELF and existing V1 owners.",
|
||||
"canonical_state": ["world schema owned by world_role", "configuration schema owned by configuration_role", "self schema owned by self_role", "no cross-owner canonical writes"],
|
||||
"state_transitions": ["missing Gen2 roles/schemas -> idempotently provisioned -> migrations 046-050 apply -> restart/recovery preserves schema and migration state"],
|
||||
"side_effects_and_privileges": ["Create only the three declared LOGIN owner roles when absent.", "Revoke PUBLIC schema privileges and fail closed on an unexpected schema owner.", "Do not invent or print credentials; do not grant cross-owner access."],
|
||||
"persistence_and_migrations": ["Provisioning runs in the dedicated bootstrap boundary before feature migrations.", "Migrations 046-050 remain unchanged and are applied by the existing migration history runner."],
|
||||
"failure_retry_restart": ["Provisioning is safe to run repeatedly.", "Unexpected existing role/schema ownership fails closed and requires an architecture decision.", "PostgreSQL restart/recovery must retain owner roles, schemas, ACL and migration history."],
|
||||
"assumptions": ["This is a user-authorized remediation task for the previously stopped GEN2-027 migration decision.", "No Gen2 runtime consumer currently requires a new database password; credentials are not invented by this task.", "GEN2-027 resumes only after provisioning and recovery evidence; GEN2-028 is not started."],
|
||||
"acceptance_scenarios": ["GEN2-DB-001-SCN-01: all roles required by migrations 046-050 are identified", "GEN2-DB-001-SCN-02: provisioning succeeds twice without duplicate objects", "GEN2-DB-001-SCN-03: migrations 046-050 apply in the controlled deployment path", "GEN2-DB-001-SCN-04: schema owners and PUBLIC ACL are confirmed", "GEN2-DB-001-SCN-05: PostgreSQL restart/recovery preserves the result", "GEN2-DB-001-EXIT: migration blocker is removed without weakening V1 isolation"],
|
||||
"required_tests": ["FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST whitespace: git diff --check gen2-integration --", "TARGETED: provisioning repeated twice, migrations 046-050, ACL/ownership verification and PostgreSQL restart/recovery"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"task_id": "GEN2-DEPLOY-001",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Развернуть независимый Supervisor control plane с durable release state, controlled traffic switching и доказуемым rollback перед GEN2-027.",
|
||||
"requirement_ids": ["REQ-GEN2-DEPLOY-001-SUPERVISOR", "REQ-GEN2-DEPLOY-001-TRAFFIC", "REQ-GEN2-DEPLOY-001-ROLLBACK", "REQ-GEN2-DEPLOY-001-LIFEBOAT"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["deployment control", "runtime supervisor", "runtime health"],
|
||||
"paths": [
|
||||
"deploy/**",
|
||||
"runtime/supervisor/**",
|
||||
"runtime/health/**",
|
||||
"cmd/supervisor/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json",
|
||||
"docs/evidence/**"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["GEN2-028", "database/**", "security/**", "architecture/**", "Authority/Commit bypass", "cognitive-lifeboat as Supervisor"]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["GEN2-027 production canary contract", "runtime/supervisor durable state", "existing deployment update controller"], "may_change": ["deployment topology, Supervisor HTTP control contract and traffic routing only"]},
|
||||
"semantic_owner": "Operations/Supervisor; not a semantic owner and not a cognitive provider.",
|
||||
"canonical_state": ["durable release/current/last-known-good evidence owned by Supervisor control plane", "traffic target is Supervisor-owned operational state"],
|
||||
"state_transitions": ["baseline -> candidate -> observed healthy -> header canary -> promoted OR independent rollback to last-known-good"],
|
||||
"side_effects_and_privileges": ["Control API is bound only to the production-internal loopback port.", "Supervisor has no database, Authority or Commit credentials.", "Candidate traffic is explicit and rollback remains available."],
|
||||
"persistence_and_migrations": ["No database changes; durable state is stored in a dedicated named volume outside candidate containers."],
|
||||
"failure_retry_restart": ["Supervisor restart reloads release and traffic state from its volume.", "Candidate failure must not erase last-known-good or block stable traffic rollback.", "Lifeboat model failure remains a separately reported degraded provider condition."],
|
||||
"assumptions": ["GEN2-027 resumes only after this task's production evidence; GEN2-028 is not started.", "The existing public ingress forwards to the configured creator web host port."],
|
||||
"acceptance_scenarios": ["GEN2-DEPLOY-001-SCN-01: Supervisor is a separate healthy production container", "GEN2-DEPLOY-001-SCN-02: release state survives Supervisor restart", "GEN2-DEPLOY-001-SCN-03: canary header reaches candidate while default traffic stays stable", "GEN2-DEPLOY-001-SCN-04: candidate failure rolls traffic back to last-known-good", "GEN2-DEPLOY-001-SCN-05: cognitive lifeboat diagnosis and degraded role are explicit", "GEN2-DEPLOY-001-EXIT: GEN2-027 rollback prerequisites are proven"],
|
||||
"required_tests": ["FAST: go test ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST: go vet ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST governance: preflight", "FAST scope: check-diff", "FAST independent: independent", "FAST whitespace: git diff --check", "TARGETED recovery: Supervisor restart, canary and rollback on docker.cin.su"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Расширить существующий Supervisor до безопасного управления единым candidate/stable ReleaseSet из creator-web, core-runtime, cognitive-worker и research-runtime без изменения Gen2 semantic ownership.",
|
||||
"requirement_ids": ["REQ-GEN2-DEPLOY-002-RELEASE-SET", "REQ-GEN2-DEPLOY-002-CANDIDATE-TOPOLOGY", "REQ-GEN2-DEPLOY-002-ROLLBACK", "REQ-GEN2-DEPLOY-002-COMPAT-OBS"],
|
||||
"allowed_change_scope": {"modules": ["runtime/supervisor"], "paths": ["runtime/supervisor/**", "cmd/supervisor/**", "deploy/**", "docs/**", "tests/**", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-DEPLOY-002-supervised-release-set.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "modules/**", "contracts/**", "security/**", "go.mod", "go.sum", ".gitea/workflows/**", "adapters/models/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["existing Supervisor durable state and production compose", "existing canary header and stable rollback controls"], "may_change": ["Supervisor release control payloads", "deployment topology and candidate routing configuration"]},
|
||||
"semantic_owner": "Supervisor/deployment control plane; ReleaseSet is operational release state and not semantic canonical state.",
|
||||
"canonical_state": ["no semantic owner or schema change", "previous stable release and evidence remain retained"],
|
||||
"state_transitions": ["candidate ReleaseSet -> component health/revision checks -> canary -> observation -> promotion or whole-set rollback"],
|
||||
"side_effects_and_privileges": ["production deployment only through approved control plane; no direct stable replacement; no credential reads"],
|
||||
"persistence_and_migrations": ["durable Supervisor release state remains in existing external Supervisor volume; no database migration"],
|
||||
"assumptions": ["the existing production Supervisor and compose control plane remain authoritative for deployment", "candidate services may share existing durable infrastructure only under current ownership and compatibility rules", "no credentials are read by this task"],
|
||||
"failure_retry_restart": ["candidate component failure routes all traffic to previous stable ReleaseSet", "Supervisor restart reloads ReleaseSet state", "previous stable is retained until promotion is complete"],
|
||||
"acceptance_scenarios": ["DEPLOY-002-SCN-01: complete ReleaseSet has all four components and one revision", "DEPLOY-002-SCN-02: candidate dependencies resolve only to candidate services", "DEPLOY-002-SCN-03: health/readiness/revision checks cover all required components", "DEPLOY-002-SCN-04: canary and stable paths are isolated", "DEPLOY-002-SCN-05: forced candidate failure rolls back the complete ReleaseSet", "DEPLOY-002-SCN-06: Supervisor restart preserves stable/candidate/LKG state", "DEPLOY-002-SCN-07: production telemetry revision starts bounded observation after controlled deployment"],
|
||||
"required_tests": ["go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted supervisor/release-set tests", "FAST: compose config validation", "FAST: routing and revision consistency tests", "FAST: rollback/restart persistence tests", "FAST: targeted go vet", "FAST governance: preflight/check-diff/independent", "FAST whitespace: git diff --check", "PRODUCTION: candidate ReleaseSet drill and telemetry observation start"],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
# Assistant project status
|
||||
|
||||
## Gen2 M6 checkpoint — 2026-08-30
|
||||
|
||||
- `GEN2-028 = COMPLETED_WITH_EVIDENCE`: V1 compatibility inventory is recorded in `docs/evidence/gen2-028-v1-compatibility-inventory-2026-08-30.json`. Current production callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; no path was proven `SAFE_TO_RETIRE`.
|
||||
- `GEN2-029 = COMPLETED_WITH_SAFE_NOOP`: no V1 runtime path or historical evidence was deleted or disabled. The decision and rollback point are recorded in `docs/evidence/gen2-029-v1-retirement-decision-2026-08-30.json`; `FULL_RETIREMENT` was not applicable because no irreversible retirement was performed.
|
||||
- `GEN2-030_STATUS = ACCEPTANCE_IN_PROGRESS`: this status is a documentation checkpoint, not a final Gen2 PASS. The final gate must confirm current production and repository evidence; the M6 retirement gap remains that active V1 compatibility callers have not yet been proven unused.
|
||||
- `UI_BROWSER_ACCEPTANCE = DEFERRED_BY_OWNER`; it is intentionally excluded from core/M6 acceptance and is neither counted as PASS nor as a blocker.
|
||||
- `PROVIDER_GAPS = CPU_FAST_UNAVAILABLE; GPU_UNAVAILABLE`; Local MEDIUM remains the primary route and Lifeboat remains fallback per existing M5 evidence.
|
||||
- `GEN2_M6_FINAL_STATUS = NOT_FINAL_PASS_RETIREMENT_GAP_REMAINS`; FULL_FINAL technical checks passed, but no final M6 baseline tag is created because active V1 compatibility callers were not proven safe to retire.
|
||||
- `GEN2_M6_FINAL_EVIDENCE = docs/evidence/gen2-030-final-acceptance-2026-08-30.json`.
|
||||
- `GEN2_FINAL_STATUS = GEN2_CORE_ACCEPTED / V1_COMPATIBILITY_RETAINED / UI_DEFERRED`.
|
||||
- `GEN2-COMPAT-OBS-001 = INSTRUMENTATION_COMMITTED; PRODUCTION_OBSERVATION_BLOCKED_BY_MISSING_CAPABILITY`; evidence: `docs/evidence/gen2-compat-obs-001-blocked-2026-08-30.json`. No V1 path is `SAFE_TO_RETIRE`; no retirement task was created.
|
||||
- `NEXT_EVOLUTION_QUEUE = instrument bounded compatibility usage, observe, then retire only individually proven paths under a future FULL_RETIREMENT gate; no Gen3 or new feature work is started.
|
||||
|
||||
## Current audit delta — 2026-08-24
|
||||
|
||||
- `CURRENT_MODEL_SCOPE = LOCAL_FIRST_V1`; one locally available Qwen3-8B or Gemma-4 conversational model is sufficient for the first stage, with the local Lifeboat as recovery fallback. External models remain supported as optional capability, but their absence must not block work: reflexes, skills and local executors provide degraded continuation. External/GPU qualification and promotion are intentionally deferred by DEV-000270.
|
||||
|
||||
@@ -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,98 @@
|
||||
{
|
||||
"evidence_id": "gen2-028-v1-compatibility-inventory-2026-08-30",
|
||||
"task_id": "GEN2-028",
|
||||
"task_revision": 2,
|
||||
"source_revision": "93feb9bf2df8f2e2cfd4d0259d39c869b19cad08",
|
||||
"scope": "static repository inventory plus read-only production container and Supervisor observations; no code, schema, or runtime mutation",
|
||||
"canonical_writer_rule": "unchanged; no second Memory owner and no writer cutover performed",
|
||||
"production_observation": {
|
||||
"deployment_revision": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"core_container": "running, restart_count=0; /healthz=200",
|
||||
"cognitive_worker": "running, restart_count=0, health=healthy",
|
||||
"lifeboat": "running, restart_count=0, health=healthy",
|
||||
"supervisor": "running, restart_count=0, health=healthy",
|
||||
"traffic_mode": "CANDIDATE",
|
||||
"last_known_good": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"legacy_named_runtime_containers": "none observed"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"id": "personal-memory-context-text",
|
||||
"paths": ["modules/interaction/domain/personal_memory.go", "modules/interaction/domain/gen2_context.go", "cmd/core-runtime/main.go"],
|
||||
"role": "V1 rebuildable context projection and deterministic preference recall",
|
||||
"runtime_status": "ACTIVE_COMPATIBILITY_PATH",
|
||||
"evidence": ["BuildScopedGen2Context is called from the production chat handler", "CompilePersonalMemory and ContextText remain in that call path", "empty Gen2 projection returns personal-memory-v1-fallback", "targeted gen2_context and personal_memory tests exist"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain the existing read fallback and revert the reader switch without deleting utterance history"
|
||||
},
|
||||
{
|
||||
"id": "active-project-focus",
|
||||
"paths": ["adapters/postgres/situation_repository.go", "cmd/core-runtime/main.go", "contracts/situation/types.go"],
|
||||
"role": "V1 project-centric focus compatibility projection",
|
||||
"runtime_status": "ACTIVE_READ_AND_WRITE_COMPATIBILITY_PATH",
|
||||
"evidence": ["core chat loads ActiveProjectFocus for each authenticated message", "core persists it after semantic interpretation", "PostgreSQL adapter reads and upserts situation.active_project_focus", "Focus V2 projection exists but does not remove these callers"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "keep the projection readable and restore the prior focus reader if a V2 reader is rolled back"
|
||||
},
|
||||
{
|
||||
"id": "truth-bundles",
|
||||
"paths": ["adapters/postgres/epistemics_repository.go", "modules/epistemics/application/persisted_research.go", "database/migrations/034_epistemics_truth_bundles.sql", "tests/research_contracts/truth_persistence_sql_test.go"],
|
||||
"role": "V1 denormalized epistemic compatibility/status and research persistence",
|
||||
"runtime_status": "ACTIVE_WRITER_AND_READ_COMPATIBILITY_PATH",
|
||||
"evidence": ["SaveTruthBundle remains an application repository contract used by persisted research", "TruthRuntimeStatus reads counts and grouped status from epistemics.truth_bundles", "migration 034 and its contract test are retained", "no production evidence permits declaring the reader unused"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain table and reader; revert normalized reader selection without deleting historical bundles"
|
||||
},
|
||||
{
|
||||
"id": "ordinary-env-configuration",
|
||||
"paths": ["cmd/core-runtime/main.go", "cmd/work-graph-runtime/main.go", "cmd/reflection-worker/main.go", "cmd/notification-receiver/main.go"],
|
||||
"role": "ENV configuration and secret/bootstrap resolution",
|
||||
"runtime_status": "ACTIVE_MIXED_BOOTSTRAP_AND_NON_BOOTSTRAP_READS",
|
||||
"evidence": ["production composition supplies runtime settings through process configuration", "source contains direct os.Getenv reads for listener, database, provider and runtime settings", "secret values were not read or emitted", "no complete production observation proves every non-bootstrap consumer is unused"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "restore the prior ENV fallback for a single setting while Configuration remains authoritative"
|
||||
},
|
||||
{
|
||||
"id": "skill-profile-v1",
|
||||
"paths": ["adapters/postgres/capability_repository.go", "cmd/core-runtime/learning.go", "contracts/capability/resolution.go"],
|
||||
"role": "V1 SkillProfile storage/read compatibility and FastPath learning",
|
||||
"runtime_status": "ACTIVE_READ_AND_WRITE_PATH",
|
||||
"evidence": ["core exposes RecentSkillProfiles and records FastPath decline learning", "learning.go loads and saves SkillProfile", "PostgreSQL adapter writes and joins capability.skill_profiles", "explicit Skill/Reflex types exist but do not prove these callers are dead"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain SkillProfile rows and reader; revert Skill V2 selection without data deletion"
|
||||
},
|
||||
{
|
||||
"id": "implicit-fast-path",
|
||||
"paths": ["modules/capability/domain/skill.go", "modules/capability/domain/reflex.go", "cmd/core-runtime/learning.go"],
|
||||
"role": "V1 FastPath behavior and fallback-to-thinking compatibility",
|
||||
"runtime_status": "ACTIVE_COMPATIBILITY_PATH",
|
||||
"evidence": ["FastPath decisions are implemented and tested", "core records FastPath decline experiences", "ReflexFallback explicitly maps escalation to skill->reasoning", "no runtime telemetry separates all FastPath invocations from explicit Reflex use"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "restore FastPath decline/escalation behavior; preserve evidence and Skill/Reflex revisions"
|
||||
},
|
||||
{
|
||||
"id": "migration-feature-switches",
|
||||
"paths": ["development/GEN2_MIGRATION_MAP.md", "runtime/**", "modules/**"],
|
||||
"role": "documented migration switch family and compatibility strategy",
|
||||
"runtime_status": "UNKNOWN_NO_DEDICATED_RUNTIME_TELEMETRY_FOUND",
|
||||
"evidence": ["migration map defines switch names such as Focus, Context, Epistemics, Config and Skill strategies", "repository scan found no complete operational switch registry/telemetry proving zero production use", "test-only legacy mode references are not sufficient production evidence"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "do not remove a switch until production observation identifies its callers and a reversible disable path"
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"searched": ["modules", "contracts", "cmd", "adapters", "runtime", "platform", "tests", "docs", "database/migrations"],
|
||||
"terms": ["PersonalMemory", "CompilePersonalMemory", "ContextText", "ActiveProjectFocus", "truth_bundles", "skill_profiles", "FastPath", "os.Getenv", "LookupEnv", "compatibility", "legacy", "feature switch"],
|
||||
"limitations": ["No secret values were inspected", "Container logs and Supervisor state provide no per-path legacy usage counters", "A code reference is evidence of availability/callability, not a claim that every branch executed in the observation window"]
|
||||
},
|
||||
"retirement_list": [
|
||||
{"path": "personal-memory-context-text", "decision": "KEEP_LONGER", "reason": "active production chat fallback and no usage counter"},
|
||||
{"path": "active-project-focus", "decision": "KEEP_LONGER", "reason": "core read/write callers remain active"},
|
||||
{"path": "truth-bundles", "decision": "KEEP_LONGER", "reason": "research writer and status reader remain active"},
|
||||
{"path": "ordinary-env-configuration", "decision": "KEEP_LONGER", "reason": "mixed bootstrap/non-bootstrap consumers remain"},
|
||||
{"path": "skill-profile-v1", "decision": "KEEP_LONGER", "reason": "core learning and repository read/write callers remain active"},
|
||||
{"path": "implicit-fast-path", "decision": "KEEP_LONGER", "reason": "active learning/fallback path and no complete production telemetry"},
|
||||
{"path": "migration-feature-switches", "decision": "KEEP_LONGER", "reason": "no complete runtime observation evidence"}
|
||||
],
|
||||
"conclusion": "GEN2-028 EXIT: every inventoried legacy surface is classified ACTIVE_COMPATIBILITY_PATH, ACTIVE_READ_AND_WRITE_COMPATIBILITY_PATH, or UNKNOWN_NO_DEDICATED_RUNTIME_TELEMETRY_FOUND. No surface is SAFE_TO_RETIRE on current evidence; GEN2-029 must not perform irreversible deletion or disablement based on this inventory."
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"evidence_id": "gen2-029-v1-retirement-decision-2026-08-30",
|
||||
"task_id": "GEN2-029",
|
||||
"task_revision": 2,
|
||||
"source_revision": "59d32143bf4ee7b4a78cb714ec0cbd76d4d8bdf5",
|
||||
"decision": "NO_IRREVERSIBLE_RETIREMENT",
|
||||
"reason": "GEN2-028 found no SAFE_TO_RETIRE path. Active callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; migration switches lack complete production telemetry.",
|
||||
"changed_runtime_paths": [],
|
||||
"changed_schema_paths": [],
|
||||
"retired_paths": [],
|
||||
"preserved": [
|
||||
"human utterances and interaction history",
|
||||
"epistemic observations, claims, experiences, truth_bundles and audit lineage",
|
||||
"ActiveProjectFocus compatibility projection",
|
||||
"PersonalMemory read fallback",
|
||||
"SkillProfile/FastPath compatibility",
|
||||
"ordinary ENV fallback consumers until bootstrap/config observation is complete",
|
||||
"all existing Gen2 canonical writers and owner boundaries"
|
||||
],
|
||||
"full_retirement_gate": {
|
||||
"status": "NOT_APPLICABLE",
|
||||
"reason": "No irreversible V1 schema/data deletion or runtime disablement was authorized or performed; the execution contract requires the gate immediately before such a retirement.",
|
||||
"repository_full_test": "NOT_RUN_BY_DESIGN; no retirement change risk to justify expanding beyond the task's FAST/no-op decision"
|
||||
},
|
||||
"rollback": {
|
||||
"point": "59d32143bf4ee7b4a78cb714ec0cbd76d4d8bdf5",
|
||||
"method": "keep current compatibility paths and revert this evidence-only decision; no schema rollback or data deletion needed"
|
||||
},
|
||||
"conclusion": "No unsafe V1 path was removed. Historical evidence remains available, and there is no new dual writer. Retirement remains pending an observation window that can prove each path unused or a future approved cutover decision."
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"evidence_id": "gen2-030-final-acceptance-2026-08-30",
|
||||
"task_id": "GEN2-030",
|
||||
"task_revision": 2,
|
||||
"source_revision": "2a8e4e1b81226a1acedfd746ebc2aeb0ac11158f",
|
||||
"status": "NOT_FINAL_PASS_RETIREMENT_GAP_REMAINS",
|
||||
"full_final_gate": {
|
||||
"go_test_all": "PASS",
|
||||
"go_vet_all": "PASS",
|
||||
"architecture_checks": "PASS",
|
||||
"governance_preflight_check_diff_independent_whitespace": "PASS",
|
||||
"migration_consistency": "PASS; 52 files, unique range 001..052; no M6 schema changes",
|
||||
"production_core_smoke": "PASS; core /healthz=200, /readyz=200; cognitive-worker healthy; Lifeboat healthy",
|
||||
"supervisor_recovery_readiness": "PASS; current and last-known-good 3182092d5cc758f5a536ce10f5428b5d782bb961; rollback target retained"
|
||||
},
|
||||
"architecture_reconciliation": {
|
||||
"registry": "YAML/JSON mirrors agree; Gen2 owners and runtime topology are represented",
|
||||
"canonical_memory_owner": "none; retrieval/context remain projection fabric",
|
||||
"schema": "migrations 046..052 are present and numbered without duplicates; historical 001..045 retained",
|
||||
"production_revision": "3182092d5cc758f5a536ce10f5428b5d782bb961"
|
||||
},
|
||||
"m6_result": {
|
||||
"gen2_028": "completed inventory; no SAFE_TO_RETIRE path proven",
|
||||
"gen2_029": "completed safe no-op; no runtime/schema/data deletion",
|
||||
"gen2_030": "full final verification passed, but final M6 exit is not signed",
|
||||
"full_retirement": "NOT_APPLICABLE; no irreversible retirement was performed",
|
||||
"final_tag": "NOT_CREATED; acceptance is not a final PASS"
|
||||
},
|
||||
"ui_browser": "DEFERRED_BY_OWNER; intentionally excluded, neither PASS nor blocker",
|
||||
"provider_gaps": ["CPU_FAST unavailable", "GPU unavailable"],
|
||||
"remaining_gaps": [
|
||||
"Per-path production observation telemetry is missing for migration switches and several compatibility readers",
|
||||
"PersonalMemory, ActiveProjectFocus, truth_bundles, ordinary ENV, SkillProfile/FastPath still have active callers and require a future evidence-backed retirement/cutover task",
|
||||
"M6 cannot claim complete V1 retirement until those paths are proven unused or explicitly retained by an accepted architecture decision"
|
||||
],
|
||||
"next_evolution_queue": [
|
||||
"Instrument bounded, redacted usage observations for each remaining compatibility path",
|
||||
"Run an approved observation window and re-evaluate retirement candidates",
|
||||
"Retire only individually proven paths with FULL_RETIREMENT immediately before irreversible change",
|
||||
"Keep UI/browser acceptance deferred by owner",
|
||||
"Keep CPU_FAST/GPU provider qualification separate from Core acceptance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"evidence_id": "gen2-compat-obs-001-blocked-2026-08-30",
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 1,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"instrumentation_commit": "3efcbbd",
|
||||
"integration_commit": "346e0ac",
|
||||
"final_status": "GEN2_CORE_ACCEPTED / V1_COMPATIBILITY_RETAINED / UI_DEFERRED",
|
||||
"instrumented_paths": ["PersonalMemory fallback", "ActiveProjectFocus read", "ActiveProjectFocus write", "truth_bundles writer", "truth_bundles status reader", "non-bootstrap ENV consumers", "SkillProfile V1", "FastPath V1", "migration/compatibility switches"],
|
||||
"telemetry_contract": {
|
||||
"fields": ["invocation_count", "unique_request_key_hash", "unique_session_key_hash", "last_used_at", "caller_component", "read_or_write", "reason", "gen2_path_available", "fallback_or_primary", "success_or_failure", "correlation_id_hash"],
|
||||
"content_policy": "No memory content, secrets, or user data is emitted; identifiers are hashed immediately and telemetry is an operational projection, not a canonical owner."
|
||||
},
|
||||
"fast_checks": {
|
||||
"go_test": "PASS: targeted telemetry, core-runtime, interaction, capability, and postgres packages",
|
||||
"go_vet": "PASS: same targeted packages",
|
||||
"preflight": "PASS",
|
||||
"check_diff": "PASS",
|
||||
"independent_governance": "PASS",
|
||||
"git_diff_check": "PASS"
|
||||
},
|
||||
"production_observation": {"deployment": "NOT_RUN", "smoke": "NOT_RUN", "observation_window": "NOT_STARTED", "production_mutation": "NONE"},
|
||||
"blocker": {
|
||||
"supervisor_topology": "Supervisor has stable/candidate routing for creator-web only; core-runtime, cognitive-worker, and research-runtime have no candidate targets in the production compose/startup model.",
|
||||
"missing_capability": "An approved production deployment flow capable of deploying the instrumented core/cognitive/research candidate, such as configured Portainer automation or an approved candidate-capable deployment topology.",
|
||||
"unsafe_alternative_not_used": "Directly replacing core-runtime with compose would mutate the stable runtime outside the Supervisor candidate path and was not performed."
|
||||
},
|
||||
"retirement": {"safe_to_retire": [], "retirement_tasks_created": [], "classification": "UNKNOWN until production observation evidence exists", "rollback": "All existing V1 compatibility paths and historical evidence remain intact."},
|
||||
"scope": {"schema_or_migrations_changed": false, "canonical_owner_changed": false, "behavior_changed": false, "ui_changed": false, "gen3_started": false}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 2,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"observed_at_utc": "2026-08-30T10:13:06.5676592Z",
|
||||
"deployment_revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb",
|
||||
"deployment_scope": "candidate-only; no Supervisor traffic change or promotion",
|
||||
"stable_release_set": {
|
||||
"traffic_mode": "STABLE",
|
||||
"revision": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"container_ids_before_protected_operation": {
|
||||
"creator-web": "9578e7c7e0137ba36a7fc75ffaf9fce33be046e38531c9de99c0b75c2ce465f8",
|
||||
"core-runtime": "8281ba60b234825d6ee965887e8eef10e29a0783264c05d39cbe937f1b780530",
|
||||
"cognitive-worker": "987640770cad44a0b05a47dbf08a4b47a9225f8030f8bc81721fdc0834e2ee4c",
|
||||
"research-runtime": "7e7d6e5fc050b5bde5073f0939e5a9652a1f68be0a75104cc0b486ec0cd745a6"
|
||||
},
|
||||
"container_ids_after_protected_operation": {
|
||||
"creator-web": "9578e7c7e0137ba36a7fc75ffaf9fce33be046e38531c9de99c0b75c2ce465f8",
|
||||
"core-runtime": "8281ba60b234825d6ee965887e8eef10e29a0783264c05d39cbe937f1b780530",
|
||||
"cognitive-worker": "987640770cad44a0b05a47dbf08a4b47a9225f8030f8bc81721fdc0834e2ee4c",
|
||||
"research-runtime": "7e7d6e5fc050b5bde5073f0939e5a9652a1f68be0a75104cc0b486ec0cd745a6"
|
||||
},
|
||||
"stable_ids_unchanged": true
|
||||
},
|
||||
"candidate_components": {
|
||||
"creator-web": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"core-runtime": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"cognitive-worker": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"research-runtime": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"}
|
||||
},
|
||||
"auth_harness": {
|
||||
"approved_harness_found": false,
|
||||
"available_mechanisms": ["/api/session", "tests/creator_web/verify-remote.ps1", "tests/cognitive_advisory/verify-remote.ps1"],
|
||||
"gap": "available remote scripts require production password or bootstrap token; no sanctioned test principal/service harness without reading a credential was found",
|
||||
"browser_or_secret_bypass_used": false
|
||||
},
|
||||
"telemetry_observation": {
|
||||
"started": false,
|
||||
"reason": "no legitimate authenticated generation traffic; candidate was intentionally not promoted",
|
||||
"v1_paths_deleted": false
|
||||
},
|
||||
"deployment_incident": {
|
||||
"occurred": true,
|
||||
"cause": "first attempted checkout used stale local origin/gen2-integration ref and ran pre-guard script",
|
||||
"effect": "core stable container was recreated once; image revision remained 3182092 and stable traffic stayed active",
|
||||
"recovery": "subsequent protected candidate-only deployment used FETCH_HEAD 87ec7ef and proved stable IDs unchanged"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"status": "BLOCKED_BY_DEPLOYMENT_CONTROL",
|
||||
"observed_at_utc": "2026-08-30T10:37:07.7219511Z",
|
||||
"browser_connector": {
|
||||
"authenticated_tab_visible": true,
|
||||
"stale_login_tab_also_present": true,
|
||||
"cookies_tokens_or_passwords_read": false
|
||||
},
|
||||
"requested_candidate_revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb",
|
||||
"candidate_prepare": "not_completed_as_a_safe_release_set_promotion",
|
||||
"failure": {
|
||||
"phase": "candidate creator startup after cognitive readiness",
|
||||
"result": "stable container IDs changed despite candidate-only --no-deps; Supervisor control became temporarily unavailable",
|
||||
"stable_ids_before": {
|
||||
"creator-web": "4df348752f1a6def05acb1934bfc23048902ad6f235a7da967806d481e9ce7b7",
|
||||
"core-runtime": "a739d2d5c713dde575f571b38e58ec33143994aa9e3bcc2f5d5fc765a50e1796",
|
||||
"cognitive-worker": "a33d61b32116711287a166e0cce6362dc10de321f920549ef8a344fe0b5efb2d",
|
||||
"research-runtime": "88cfade5cdce288576b1c73493f83c84d915ac82c3d16dc6d3fedb9579a4ba8c"
|
||||
},
|
||||
"stable_ids_after": {
|
||||
"creator-web": "5181e5c6b48eaed6c6ea85eff5668c0e675b72bb982eb9425d6f39f899378ec9",
|
||||
"core-runtime": "641e8f7ae8177e4638cf806d9be1bb6f8b5ac028deb0c0c410c6ff4139032bd5",
|
||||
"cognitive-worker": "2e6d46f5b533cfeda261ceafd65ad5b5873b8aa8166bffc4ae91033a6c994d0a",
|
||||
"research-runtime": "b15d9b0e116fbd18fa726e33541ef11fe1bafe4a85210ab5ee500466a20b400b"
|
||||
},
|
||||
"stable_ids_unchanged": false,
|
||||
"cause": "external production updater/Compose reconcile reacts to production checkout or candidate Compose operation; --no-deps alone does not isolate stable in this control plane"
|
||||
},
|
||||
"recovery": {
|
||||
"traffic_mode": "STABLE",
|
||||
"revision": "91f0c9e7999907a4ee7665b77742dd452c18b17b",
|
||||
"healthz": 200,
|
||||
"readyz": 200,
|
||||
"supervisor_baseline_aligned": true,
|
||||
"data_or_volumes_deleted": false
|
||||
},
|
||||
"observation": {
|
||||
"authenticated_workload_sent": false,
|
||||
"telemetry_promoted": false,
|
||||
"baseline_started": false,
|
||||
"tag_created": false,
|
||||
"v1_paths_deleted": false
|
||||
},
|
||||
"required_follow_up": "deployment control plane must isolate candidate Compose project or disable updater reconcile before safe ReleaseSet promotion"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"evidence_id": "gen2-deploy-002-blocked-2026-08-30",
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"implementation_checks": {
|
||||
"release_set_tests": "PASS",
|
||||
"supervisor_go_test": "PASS",
|
||||
"supervisor_go_vet": "PASS",
|
||||
"compose_config": "PASS",
|
||||
"canary_script_syntax": "PASS",
|
||||
"governance": "PASS",
|
||||
"git_diff_check": "PASS"
|
||||
},
|
||||
"implemented": [
|
||||
"durable Supervisor ReleaseSet state for creator-web, core-runtime, cognitive-worker, and research-runtime",
|
||||
"parallel candidate services with candidate-only core/cognitive/research dependency wiring",
|
||||
"whole-set health/readiness/revision observation, canary, promotion and rollback controls",
|
||||
"stable/candidate/LKG persistence across Supervisor restart",
|
||||
"core and research runtime compose healthchecks"
|
||||
],
|
||||
"production": {
|
||||
"deployment": "NOT_RUN",
|
||||
"candidate_release_set": "NOT_STARTED",
|
||||
"telemetry_observation": "NOT_STARTED",
|
||||
"stable_mutation": "NONE"
|
||||
},
|
||||
"capability_evidence": {
|
||||
"docker_and_compose_on_production_host": "AVAILABLE",
|
||||
"supervisor_control_endpoint": "REACHABLE",
|
||||
"portainer_api_key_in_current_execution_context": "NOT_CONFIGURED",
|
||||
"portainer_endpoint_metadata_in_current_execution_context": "NOT_CONFIGURED",
|
||||
"existing_production_candidate_services": ["creator-web-candidate"],
|
||||
"missing_capability": "Approved production deployment automation or candidate-capable control-plane access for the four-component ReleaseSet."
|
||||
},
|
||||
"unsafe_action_not_taken": "Stable containers were not replaced directly and no production compose mutation was attempted.",
|
||||
"compatibility": "No V1 compatibility path was removed or disabled; no schema, migration, semantic owner, or Gen3 change.",
|
||||
"next_action": "Owner must provide or enable the approved candidate-capable production deployment mechanism; then run the ReleaseSet drill and start bounded telemetry observation."
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"evidence_id": "gen2-deploy-002-production-drill-2026-08-30",
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"integration_revision": "7f8a05c",
|
||||
"candidate_revision": "7f8a05c",
|
||||
"stable_revision_after_rollback": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"release_set": ["creator-web", "core-runtime", "cognitive-worker", "research-runtime"],
|
||||
"drill": {
|
||||
"candidate_health_readiness_revision": "PASS; all four candidate containers healthy and image revision checked",
|
||||
"candidate_dependency_isolation": "PASS; creator->core candidate, core->research candidate, core/cognitive candidate routes verified in compose wiring",
|
||||
"canary_header": "PASS; Supervisor selected candidate for X-Assistant-Canary=true",
|
||||
"forced_component_failure": "PASS; research-runtime-candidate stopped, ReleaseSet observation rejected with HTTP 409",
|
||||
"whole_set_rollback": "PASS; Supervisor restored stable 3182092 and traffic mode STABLE",
|
||||
"promotion": "PASS; healthy candidate was promoted through Supervisor",
|
||||
"supervisor_restart": "PASS; promoted ReleaseSet and rollback target survived restart",
|
||||
"post_rollback_health": "PASS; public health/readiness both HTTP 200"
|
||||
},
|
||||
"telemetry": {
|
||||
"startup_events_observed": ["non-bootstrap-env-consumers", "migration-compatibility-switches"],
|
||||
"authenticated_path_observation": "NOT_COMPLETED",
|
||||
"observation_window": "NOT_STARTED",
|
||||
"reason": "The existing browser session was stale and production requested re-authentication before chat submission. No credentials were read or entered."
|
||||
},
|
||||
"final_production_state": {"traffic_mode": "STABLE", "revision": "3182092d5cc758f5a536ce10f5428b5d782bb961", "candidate_containers": "retained but not serving stable traffic"},
|
||||
"not_done": ["telemetry confirmation for all nine paths", "bounded observation window", "retirement tasks", "main merge", "milestone tag"],
|
||||
"scope": {"schema_or_migrations_changed": false, "v1_paths_removed": false, "gen3_started": false, "ui_changed": false}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStrategyUsesRiskAndCostSignals(t *testing.T) {
|
||||
if got := (capability.StrategyRequest{RequiredQuality: 400, CostBudgetMicros: 10}).Select().Path; got != capability.PathRule {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := (capability.StrategyRequest{RiskClass: "HIGH", CostOfError: 900}).Select(); got.Path != capability.PathStrongLocal || !got.Escalate {
|
||||
t.Fatalf("%#v", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,40 @@ package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"git.cin.su/m/Assistant/contracts/configuration"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func Resolve(definition configuration.SettingDefinition, assignments []configuration.SettingAssignment, selector configuration.ScopeSelector) (configuration.EffectiveSetting,error){if err:=definition.Validate();err!=nil{return configuration.EffectiveSetting{},err}; var matches []configuration.SettingAssignment;for _,a:=range assignments{if err:=a.Validate();err!=nil{return configuration.EffectiveSetting{},err};if a.DefinitionRef==string(definition.ID)&&a.Lifecycle==configuration.SettingActive&&a.Selector.PrincipalRef==selector.PrincipalRef&&a.Selector.SkillRef==selector.SkillRef{matches=append(matches,a)}};if len(matches)==0{return configuration.EffectiveSetting{DefinitionRef:string(definition.ID),Value:definition.DefaultValue,ResolutionTrace:[]string{"system-default"}},nil};sort.SliceStable(matches,func(i,j int)bool{return specificity(matches[i].Selector)>specificity(matches[j].Selector)});if len(matches)>1&&specificity(matches[0].Selector)==specificity(matches[1].Selector){return configuration.EffectiveSetting{},fmt.Errorf("configuration conflict")};return configuration.EffectiveSetting{DefinitionRef:string(definition.ID),Value:matches[0].TypedValue,WinningAssignmentRef:string(matches[0].ID),ResolutionTrace:[]string{"specificity-selected"}},nil}
|
||||
func specificity(s configuration.ScopeSelector)int{return len(s.RequiredContextBindings)+boolInt(s.SkillRef!="")+boolInt(s.TaskRef!="")+boolInt(s.SessionRef!="")+boolInt(s.TemporaryOverride)}
|
||||
func boolInt(v bool)int{if v{return 1};return 0}
|
||||
var ErrInvalidConfigurationCommand = fmt.Errorf("invalid conversational configuration command")
|
||||
|
||||
func Resolve(definition configuration.SettingDefinition, assignments []configuration.SettingAssignment, selector configuration.ScopeSelector) (configuration.EffectiveSetting, error) {
|
||||
if err := definition.Validate(); err != nil {
|
||||
return configuration.EffectiveSetting{}, err
|
||||
}
|
||||
var matches []configuration.SettingAssignment
|
||||
for _, a := range assignments {
|
||||
if err := a.Validate(); err != nil {
|
||||
return configuration.EffectiveSetting{}, err
|
||||
}
|
||||
if a.DefinitionRef == string(definition.ID) && a.Lifecycle == configuration.SettingActive && a.Selector.PrincipalRef == selector.PrincipalRef && a.Selector.SkillRef == selector.SkillRef {
|
||||
matches = append(matches, a)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return configuration.EffectiveSetting{DefinitionRef: string(definition.ID), Value: definition.DefaultValue, ResolutionTrace: []string{"system-default"}}, nil
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool { return specificity(matches[i].Selector) > specificity(matches[j].Selector) })
|
||||
if len(matches) > 1 && specificity(matches[0].Selector) == specificity(matches[1].Selector) {
|
||||
return configuration.EffectiveSetting{}, fmt.Errorf("configuration conflict")
|
||||
}
|
||||
return configuration.EffectiveSetting{DefinitionRef: string(definition.ID), Value: matches[0].TypedValue, WinningAssignmentRef: string(matches[0].ID), ResolutionTrace: []string{"specificity-selected"}}, nil
|
||||
}
|
||||
func specificity(s configuration.ScopeSelector) int {
|
||||
return len(s.RequiredContextBindings) + boolInt(s.SkillRef != "") + boolInt(s.TaskRef != "") + boolInt(s.SessionRef != "") + boolInt(s.TemporaryOverride)
|
||||
}
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/contracts/configuration"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrConfigurationAuthorityRequired = fmt.Errorf("configuration change requires Authority and Commit")
|
||||
|
||||
func AssignmentFromConversation(kind, principalID, definitionRef, typedValue, sessionRef, sourceRef string, temporaryOverride bool, definition configuration.SettingDefinition, now time.Time) (configuration.SettingAssignment, error) {
|
||||
if kind != "SET" || principalID == "" || definitionRef != string(definition.ID) || strings.TrimSpace(typedValue) == "" || now.IsZero() || definition.ValueType == configuration.ValueSecretRef {
|
||||
return configuration.SettingAssignment{}, ErrInvalidConfigurationCommand
|
||||
}
|
||||
if sourceRef == "" {
|
||||
return configuration.SettingAssignment{}, ErrInvalidConfigurationCommand
|
||||
}
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return configuration.SettingAssignment{}, err
|
||||
}
|
||||
return configuration.SettingAssignment{ID: configuration.SettingAssignmentID(id), PrincipalID: principalID, DefinitionRef: string(definition.ID), DefinitionRevision: definition.Revision, Revision: 1, Selector: configuration.ScopeSelector{PrincipalRef: principalID, SessionRef: sessionRef, TemporaryOverride: temporaryOverride}, TypedValue: strings.TrimSpace(typedValue), Source: configuration.SourceHumanDirective, SourceRef: sourceRef, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: now.UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func ExplainEffectiveSetting(definition configuration.SettingDefinition, effective configuration.EffectiveSetting) string {
|
||||
if effective.WinningAssignmentRef == "" {
|
||||
return fmt.Sprintf("Настройка %s сейчас имеет системное значение %q.", definition.StableKey, effective.Value)
|
||||
}
|
||||
return fmt.Sprintf("Настройка %s сейчас имеет значение %q; применено scoped-правило %s.", definition.StableKey, effective.Value, effective.WinningAssignmentRef)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/configuration"
|
||||
"git.cin.su/m/Assistant/modules/configuration/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConversationCreatesScopedAssignmentWithoutBypassingAuthority(t *testing.T) {
|
||||
d := configuration.SettingDefinition{ID: "setting:language", OwnerNamespace: "interaction", StableKey: "language", Revision: 2, ValueType: configuration.ValueString, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: 1}
|
||||
a, err := domain.AssignmentFromConversation("SET", "principal-1", "setting:language", "ru", "session-1", "utterance-1", true, d, time.UnixMilli(2))
|
||||
if err != nil || a.Selector.PrincipalRef != "principal-1" || !a.Selector.TemporaryOverride || a.Source != configuration.SourceHumanDirective {
|
||||
t.Fatalf("assignment=%+v err=%v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretConfigurationCannotReceiveConversationalValue(t *testing.T) {
|
||||
d := configuration.SettingDefinition{ID: "setting:key", OwnerNamespace: "capability", StableKey: "key", Revision: 1, ValueType: configuration.ValueSecretRef, Lifecycle: configuration.SettingActive, RecordedAtUnixMillis: 1}
|
||||
if _, err := domain.AssignmentFromConversation("SET", "p", string(d.ID), "secret", "", "u", false, d, time.UnixMilli(2)); err == nil {
|
||||
t.Fatal("secret value accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
var ErrInvalidOutcomeAssessment = errors.New("invalid world outcome assessment")
|
||||
|
||||
// AssessWorldOutcome keeps Commit's execution fact separate from Epistemics'
|
||||
// observation. No observation means UNKNOWN; a transport receipt alone never
|
||||
// becomes success.
|
||||
func AssessWorldOutcome(principalID, attemptRef, expectedEffect string, receiptRefs []string, observationRefs []epistemics.ObservationID, claimRefs []epistemics.ClaimID, evidenceRefs []epistemics.EvidenceRef, observedState epistemics.WorldOutcomeState, confidence, assessedAt int64) (epistemics.WorldOutcomeAssessment, error) {
|
||||
if principalID == "" || attemptRef == "" || expectedEffect == "" || assessedAt < 1 || confidence < 0 || confidence > 1000 {
|
||||
return epistemics.WorldOutcomeAssessment{}, ErrInvalidOutcomeAssessment
|
||||
}
|
||||
state := epistemics.OutcomeUnknownState
|
||||
if len(observationRefs) == 0 {
|
||||
state = epistemics.OutcomeUnknownState
|
||||
} else if observedState == epistemics.OutcomeVerifiedSuccess || observedState == epistemics.OutcomeVerifiedPartial || observedState == epistemics.OutcomeVerifiedFailure || observedState == epistemics.OutcomeConflicted {
|
||||
state = observedState
|
||||
} else {
|
||||
state = epistemics.OutcomeVerifiedPartial
|
||||
}
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return epistemics.WorldOutcomeAssessment{}, err
|
||||
}
|
||||
value := epistemics.WorldOutcomeAssessment{ID: string(id), PrincipalID: principalID, ActionAttemptRef: attemptRef, ExecutionReceiptRefs: append([]string(nil), receiptRefs...), ExpectedEffectRef: expectedEffect, ObservationRefs: append([]epistemics.ObservationID(nil), observationRefs...), ClaimRefs: append([]epistemics.ClaimID(nil), claimRefs...), State: state, ConfidenceMillis: confidence, EvidenceRefs: append([]epistemics.EvidenceRef(nil), evidenceRefs...), AssessedAt: assessedAt}
|
||||
if !value.Valid() {
|
||||
return epistemics.WorldOutcomeAssessment{}, ErrInvalidOutcomeAssessment
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutionReceiptWithoutObservationRemainsUnknown(t *testing.T) {
|
||||
value, err := domain.AssessWorldOutcome("principal", "attempt", "effect", []string{"receipt"}, nil, nil, nil, epistemics.OutcomeVerifiedSuccess, 1000, 1)
|
||||
if err != nil || value.State != epistemics.OutcomeUnknownState {
|
||||
t.Fatalf("value=%+v err=%v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservedOutcomeCanBeVerifiedSeparately(t *testing.T) {
|
||||
value, err := domain.AssessWorldOutcome("principal", "attempt", "effect", []string{"receipt"}, []epistemics.ObservationID{"observation"}, []epistemics.ClaimID{"claim"}, []epistemics.EvidenceRef{"evidence"}, epistemics.OutcomeVerifiedSuccess, 900, 1)
|
||||
if err != nil || value.State != epistemics.OutcomeVerifiedSuccess || len(value.EvidenceRefs) != 1 {
|
||||
t.Fatalf("value=%+v err=%v", value, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/epistemics"
|
||||
|
||||
func AssessSourceReliability(sourceRef, domainRef string, freshness epistemics.FreshnessState, historyScore, priorScore int64, evidence []string, at int64) epistemics.SourceReliabilityAssessment {
|
||||
score := (historyScore + priorScore) / 2
|
||||
state := epistemics.ReliabilityCurrent
|
||||
if freshness == epistemics.FreshnessUnknown {
|
||||
state = epistemics.ReliabilityUnknown
|
||||
} else if freshness == epistemics.FreshnessStale {
|
||||
state = epistemics.ReliabilityAging
|
||||
}
|
||||
return epistemics.SourceReliabilityAssessment{SourceRef: sourceRef, DomainRef: domainRef, Freshness: freshness, HistoryScoreMillis: historyScore, ReliabilityScoreMillis: score, State: state, EvidenceRefs: append([]string(nil), evidence...), AssessedAtUnixMillis: at}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSourceReliabilityIncludesDomainFreshnessAndHistory(t *testing.T) {
|
||||
a := domain.AssessSourceReliability("source", "weather", epistemics.FreshnessStale, 900, 700, []string{"history"}, 1)
|
||||
if !a.Valid() || a.State != epistemics.ReliabilityAging || a.ReliabilityScoreMillis != 800 {
|
||||
t.Fatalf("assessment=%+v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const creatorPasswordIterations = 120000
|
||||
|
||||
var ErrInvalidCreatorPassword = errors.New("invalid creator password")
|
||||
|
||||
func HashCreatorPassword(password string) (string, error) {
|
||||
if len(password) < 6 || len(password) > 4096 {
|
||||
return "", ErrInvalidCreatorPassword
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32)
|
||||
return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil
|
||||
}
|
||||
|
||||
func VerifyCreatorPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" {
|
||||
return false
|
||||
}
|
||||
iterations, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iterations < 100000 || iterations > 1000000 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(salt) < 16 {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil || len(expected) != 32 {
|
||||
return false
|
||||
}
|
||||
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte {
|
||||
key := make([]byte, 0, keyLength)
|
||||
for block := 1; len(key) < keyLength; block++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
key = append(key, t...)
|
||||
}
|
||||
return key[:keyLength]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreatorPasswordHashRoundTrip(t *testing.T) {
|
||||
hash, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyCreatorPassword(hash, "a-long-enough-test-password") {
|
||||
t.Fatal("hashed password was not accepted")
|
||||
}
|
||||
if VerifyCreatorPassword(hash, "wrong-password") {
|
||||
t.Fatal("wrong password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) {
|
||||
first, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := HashCreatorPassword("a-long-enough-test-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("password hashes reused a salt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) {
|
||||
if _, err := HashCreatorPassword("short"); err == nil {
|
||||
t.Fatal("short password was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
var ErrInvalidRealm = errors.New("invalid principal data realm")
|
||||
|
||||
func ValidatePrincipalDataRealm(value identity.PrincipalDataRealm) error {
|
||||
if !value.Valid() {
|
||||
return ErrInvalidRealm
|
||||
}
|
||||
if value.Kind == identity.DataRealmPersonal && len(value.MemberPrincipalIDs) != 0 {
|
||||
return ErrInvalidRealm
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CanReadRealm(value identity.PrincipalDataRealm, principalID identity.PrincipalID) bool {
|
||||
if ValidatePrincipalDataRealm(value) != nil || principalID == "" {
|
||||
return false
|
||||
}
|
||||
if value.Kind == identity.DataRealmPublic {
|
||||
return true
|
||||
}
|
||||
if principalID == value.OwnerPrincipalID {
|
||||
return true
|
||||
}
|
||||
for _, member := range value.MemberPrincipalIDs {
|
||||
if member == principalID {
|
||||
return value.Kind == identity.DataRealmShared && value.Shared
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/modules/identity/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealmSeparatesOwnerSharedAndPublicAccess(t *testing.T) {
|
||||
r := identity.PrincipalDataRealm{ID: "realm", OwnerPrincipalID: "owner", Kind: identity.DataRealmShared, Shared: true, MemberPrincipalIDs: []identity.PrincipalID{"member"}, Revision: 1, RecordedAtUnixMillis: 1}
|
||||
if !domain.CanReadRealm(r, "owner") || !domain.CanReadRealm(r, "member") || domain.CanReadRealm(r, "guest") {
|
||||
t.Fatalf("realm access leaked: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
var ErrInvalidCreatorSession = errors.New("invalid creator session")
|
||||
|
||||
func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
|
||||
if len(secret) < 32 || ttl <= 0 {
|
||||
return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl)
|
||||
}
|
||||
|
||||
func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
|
||||
if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 {
|
||||
return "", identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()}
|
||||
session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()}
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", identity.CreatorSession{}, err
|
||||
@@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
|
||||
return identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix {
|
||||
if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix {
|
||||
return identity.CreatorSession{}, ErrInvalidCreatorSession
|
||||
}
|
||||
return session, nil
|
||||
|
||||
@@ -278,6 +278,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
}
|
||||
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
|
||||
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
|
||||
confirmationWords := []string{"хорошо", "ладно", "окей", "ок", "понял", "понятно"}
|
||||
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
|
||||
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь", "вывед"}
|
||||
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
|
||||
@@ -292,6 +293,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
||||
if containsStem(token, conversationWords...) {
|
||||
scores[interaction.SpeechActConversational] += 3
|
||||
}
|
||||
if containsExactToken(token, confirmationWords...) {
|
||||
scores[interaction.SpeechActConfirmation] += 3
|
||||
}
|
||||
if containsStem(token, investigateStems...) {
|
||||
scores[interaction.SpeechActRequestToInvestigate] += 3
|
||||
scores[interaction.SpeechActQuestion]++
|
||||
@@ -616,6 +620,9 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
|
||||
if !inquiry {
|
||||
return false
|
||||
}
|
||||
if focusIntrospectionQuestion(tokens) {
|
||||
return false
|
||||
}
|
||||
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
||||
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
||||
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
|
||||
@@ -710,7 +717,7 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
if cognitiveSubject && inventoryRequest && inventoryCue {
|
||||
return interaction.InformationRequestCognitiveModelInventory
|
||||
}
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
||||
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "доступн", "инструмент", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
||||
if inquiry && capabilitySubject {
|
||||
if containsStemInTokens(tokens, "без", "внешн", "локальн") && containsStemInTokens(tokens, "модел", "ии", "ai") {
|
||||
return interaction.InformationRequestLocalCapabilityInventory
|
||||
@@ -727,6 +734,13 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
||||
return ""
|
||||
}
|
||||
|
||||
func focusIntrospectionQuestion(tokens []string) bool {
|
||||
if containsStemInTokens(tokens, "фокус", "focus", "контекст") && (containsStemInTokens(tokens, "сейчас", "текущ", "работ", "контекст") || containsMeaningfulInterrogative(tokens)) {
|
||||
return true
|
||||
}
|
||||
return containsStemInTokens(tokens, "над", "делаем", "работаем") && containsStemInTokens(tokens, "сейчас", "текущ", "мы")
|
||||
}
|
||||
|
||||
func LocationContextReply(message string) string {
|
||||
if location := personalCurrentLocation(message); location != "" {
|
||||
return "Запомнил: сейчас вы в " + location + "."
|
||||
@@ -800,6 +814,15 @@ func containsStem(token string, stems ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func containsExactToken(token string, words ...string) bool {
|
||||
for _, word := range words {
|
||||
if token == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsStemInTokens(tokens []string, stems ...string) bool {
|
||||
for _, token := range tokens {
|
||||
if containsStem(token, stems...) {
|
||||
|
||||
@@ -142,6 +142,7 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
|
||||
{"почему ты не смог найти ответ?", interaction.InformationRequestResearchFailureExplanation},
|
||||
{"что ты можешь сделать без внешних моделей?", interaction.InformationRequestLocalCapabilityInventory},
|
||||
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
||||
{"что доступно в этом чате?", interaction.InformationRequestCapabilityInventory},
|
||||
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
||||
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
||||
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
|
||||
@@ -313,6 +314,18 @@ func TestRememberedPreferenceIsNotRenderedAsIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableAcknowledgementsDoNotBecomeTravelRequests(t *testing.T) {
|
||||
for _, message := range []string{"Хорошо", "Ладно", "Понял"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||||
if candidate.PrimarySpeechAct != interaction.SpeechActConfirmation {
|
||||
t.Fatalf("%q: expected confirmation, got %s", message, candidate.PrimarySpeechAct)
|
||||
}
|
||||
if reply, stable := StableConversationalReply(message, candidate.PrimarySpeechAct); !stable || reply != "Хорошо." {
|
||||
t.Fatalf("%q: stable reply = %q, %t", message, reply, stable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
|
||||
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
|
||||
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseConfigurationCommand recognises bounded human intent and never writes
|
||||
// configuration or invokes Authority/Commit.
|
||||
func ParseConfigurationCommand(message, principalID, sourceRef string) interaction.ConfigurationCommand {
|
||||
text := strings.TrimSpace(strings.ToLower(message))
|
||||
if principalID == "" || sourceRef == "" {
|
||||
return interaction.ConfigurationCommand{}
|
||||
}
|
||||
if strings.Contains(text, "какая настройка") || strings.HasPrefix(text, "какое значение") {
|
||||
return interaction.ConfigurationCommand{Kind: interaction.ConfigurationCommandQuery, PrincipalID: principalID, SourceRef: sourceRef}
|
||||
}
|
||||
temporary := strings.Contains(text, "только сейчас") || strings.Contains(text, "на этот раз") || strings.Contains(text, "временно")
|
||||
if strings.Contains(text, "настрой") || strings.Contains(text, "установи") || strings.Contains(text, "сделай") {
|
||||
parts := strings.SplitN(message, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(parts[0]), "настройка"))
|
||||
value := strings.TrimSpace(parts[1])
|
||||
for _, marker := range []string{"только сейчас", "на этот раз", "временно"} {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(value), marker, ""))
|
||||
}
|
||||
return interaction.ConfigurationCommand{Kind: interaction.ConfigurationCommandSet, PrincipalID: principalID, DefinitionRef: key, TypedValue: value, TemporaryOverride: temporary, SourceRef: sourceRef}
|
||||
}
|
||||
}
|
||||
return interaction.ConfigurationCommand{}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConversationParserProducesIntentOnly(t *testing.T) {
|
||||
value := interactiondomain.ParseConfigurationCommand("Настройка language = ru только сейчас", "principal-1", "utterance-1")
|
||||
if value.Kind != interaction.ConfigurationCommandSet || value.DefinitionRef != "language" || value.TypedValue != "ru" || !value.TemporaryOverride {
|
||||
t.Fatalf("value=%+v", value)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,23 @@ type ContextProjection struct {
|
||||
// BuildGen2Context is a read-side projection over canonical utterance history.
|
||||
// PersonalMemory remains a compatibility fallback and is never written here.
|
||||
func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
|
||||
return BuildScopedGen2Context(history, "")
|
||||
}
|
||||
|
||||
// BuildScopedGen2Context is the Gen2 retrieval boundary for household
|
||||
// context. The caller supplies already-authorized history; this defensive
|
||||
// filter prevents accidental cross-principal projection when a mixed source
|
||||
// is passed during degraded operation.
|
||||
func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection {
|
||||
if principalID != "" {
|
||||
scoped := make([]interaction.HumanUtterance, 0, len(history))
|
||||
for _, item := range history {
|
||||
if item.PrincipalID == "" || string(item.PrincipalID) == principalID {
|
||||
scoped = append(scoped, item)
|
||||
}
|
||||
}
|
||||
history = scoped
|
||||
}
|
||||
text := CompilePersonalMemory(history).ContextText()
|
||||
if text != "" {
|
||||
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
|
||||
|
||||
@@ -3,6 +3,7 @@ package domain_test
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -16,3 +17,22 @@ func TestGen2ContextUsesRetrievalProjectionAndKeepsV1Fallback(t *testing.T) {
|
||||
t.Fatalf("fallback missing: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
|
||||
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||
{ID: "mine", PrincipalID: "principal-1", Content: "Я сейчас в Москве", ReceivedAtUnixMillis: 2},
|
||||
{ID: "other", PrincipalID: "principal-2", Content: "Я сейчас в Казани", ReceivedAtUnixMillis: 3},
|
||||
}, "principal-1")
|
||||
if got.Text == "" || strings.Contains(got.Text, "Казани") || !strings.Contains(got.Text, "Москве") {
|
||||
t.Fatalf("scoped context=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
|
||||
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
|
||||
}, "principal-1")
|
||||
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
|
||||
t.Fatalf("remembered assertion was not projected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type PersonalMemory struct {
|
||||
|
||||
type PersonalMemoryFact struct {
|
||||
Kind string
|
||||
Subject string
|
||||
Value string
|
||||
ConfidenceMillis int64
|
||||
SourceUtteranceID interaction.HumanUtteranceID
|
||||
@@ -26,6 +27,12 @@ type PersonalMemoryFact struct {
|
||||
|
||||
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
|
||||
|
||||
// personalMemoryMaxValueRunes keeps the compatibility projection bounded.
|
||||
// Canonical utterances remain complete in storage; only the rebuildable
|
||||
// cognitive context is capped so old long-form instructions cannot become a
|
||||
// provider prompt or crowd out a current household fact.
|
||||
const personalMemoryMaxValueRunes = 512
|
||||
|
||||
// CompilePersonalMemory turns direct, first-person human statements into a
|
||||
// small typed context. It deliberately preserves preferences as statements
|
||||
// rather than pretending that an arbitrary preference is a verified fact.
|
||||
@@ -48,6 +55,7 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
|
||||
if preference := personalPreference(utterance.Content); preference != "" {
|
||||
preferences = append(preferences, PersonalMemoryFact{
|
||||
Kind: "PREFERENCE",
|
||||
Subject: preferenceSubject(preference),
|
||||
Value: preference,
|
||||
ConfidenceMillis: 900,
|
||||
SourceUtteranceID: utterance.ID,
|
||||
@@ -69,6 +77,21 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
|
||||
return result
|
||||
}
|
||||
|
||||
// LatestPreference returns the current value for a semantic subject while
|
||||
// retaining older facts for historical evidence.
|
||||
func (m PersonalMemory) LatestPreference(subject string) (PersonalMemoryFact, bool) {
|
||||
var latest PersonalMemoryFact
|
||||
for _, fact := range m.Facts {
|
||||
if fact.Kind != "PREFERENCE" || fact.Subject != subject {
|
||||
continue
|
||||
}
|
||||
if latest.Kind == "" || fact.ObservedAtUnixMillis >= latest.ObservedAtUnixMillis {
|
||||
latest = fact
|
||||
}
|
||||
}
|
||||
return latest, latest.Kind != ""
|
||||
}
|
||||
|
||||
func (m PersonalMemory) CurrentLocation() string {
|
||||
for _, fact := range m.Facts {
|
||||
if fact.Kind == personalMemoryCurrentLocation {
|
||||
@@ -97,6 +120,47 @@ func (m PersonalMemory) ContextText() string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// RecallPersonalPreference answers the narrow, read-only memory question from
|
||||
// the rebuildable projection. It is intentionally conservative: only an
|
||||
// explicit question about the remembered test color is handled here; other
|
||||
// questions continue through the normal cognitive route.
|
||||
func RecallPersonalPreference(history []interaction.HumanUtterance, question string) string {
|
||||
lowerQuestion := strings.ToLower(question)
|
||||
if !strings.Contains(lowerQuestion, "цвет") || (!strings.Contains(lowerQuestion, "какой") && !strings.Contains(lowerQuestion, "назвал")) {
|
||||
return ""
|
||||
}
|
||||
memory := CompilePersonalMemory(history)
|
||||
for _, fact := range memory.Facts {
|
||||
if fact.Kind != "PREFERENCE" || fact.Subject != "тестовый цвет" {
|
||||
continue
|
||||
}
|
||||
current, ok := memory.LatestPreference(fact.Subject)
|
||||
if !ok || current.SourceUtteranceID != fact.SourceUtteranceID {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(current.Value)
|
||||
if separator := strings.Index(value, "—"); separator >= 0 {
|
||||
value = strings.TrimSpace(value[separator+len("—"):])
|
||||
}
|
||||
if value != "" {
|
||||
return "Вы называли тестовый цвет: " + value + "."
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func preferenceSubject(preference string) string {
|
||||
if separator := strings.Index(preference, "—"); separator >= 0 {
|
||||
preference = preference[:separator]
|
||||
}
|
||||
subject := strings.TrimSpace(preference)
|
||||
// Operational markers belong to the preference value, not a new subject.
|
||||
if strings.HasPrefix(strings.ToLower(subject), "тестовый цвет") {
|
||||
return "тестовый цвет"
|
||||
}
|
||||
return subject
|
||||
}
|
||||
|
||||
func personalCurrentLocation(message string) string {
|
||||
tokens := semanticTokens(message)
|
||||
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
|
||||
@@ -119,9 +183,35 @@ func personalPreference(message string) string {
|
||||
}
|
||||
value := strings.TrimSpace(lower[index+len(cue):])
|
||||
value = strings.Trim(value, " .,!?:;—-")
|
||||
if len([]rune(value)) >= 2 {
|
||||
if len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
|
||||
return value
|
||||
}
|
||||
}
|
||||
// Explicit remembered assertions are still human-provided context, not
|
||||
// verified facts. Project the compact key/value statement without storing a
|
||||
// second copy outside the canonical utterance history.
|
||||
if index := strings.Index(lower, "запомни"); index >= 0 {
|
||||
remembered := strings.TrimSpace(lower[index+len("запомни"):])
|
||||
remembered = strings.TrimLeft(remembered, " :—-")
|
||||
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
|
||||
if !strings.HasPrefix(remembered, prefix) {
|
||||
continue
|
||||
}
|
||||
statement := strings.TrimSpace(remembered[len(prefix):])
|
||||
separator := strings.IndexAny(statement, "—-:")
|
||||
if separator <= 0 || separator >= len(statement)-1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(statement[:separator])
|
||||
separatorWidth := 1
|
||||
if strings.HasPrefix(statement[separator:], "—") {
|
||||
separatorWidth = len("—")
|
||||
}
|
||||
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
|
||||
if key != "" && len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
|
||||
return key + " — " + value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -28,3 +28,51 @@ func TestCompilePersonalMemoryDoesNotTreatThirdPartyStatementAsPersonalFact(t *t
|
||||
t.Fatalf("ordinary factual question became personal memory: %+v", memory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryBoundsLongRememberedValues(t *testing.T) {
|
||||
longValue := strings.Repeat("x", 513)
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{{
|
||||
Content: "запомни: мой тестовый цвет — " + longValue,
|
||||
ReceivedAtUnixMillis: 100,
|
||||
}})
|
||||
if memory.ContextText() != "" {
|
||||
t.Fatalf("long remembered value leaked into cognitive context: %q", memory.ContextText())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallPersonalPreferenceUsesProjectedAssertion(t *testing.T) {
|
||||
answer := RecallPersonalPreference([]interaction.HumanUtterance{{
|
||||
Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914",
|
||||
}}, "Какой тестовый цвет я назвал?")
|
||||
if answer != "Вы называли тестовый цвет: acceptance-m5-синий-914." {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryRanksCurrentSubjectWithoutDroppingHistory(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{
|
||||
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
|
||||
{ID: "new", Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914", ReceivedAtUnixMillis: 200},
|
||||
})
|
||||
current, ok := memory.LatestPreference("тестовый цвет")
|
||||
if !ok || current.Value != "тестовый цвет — acceptance-m5-синий-914" {
|
||||
t.Fatalf("current=%+v ok=%t", current, ok)
|
||||
}
|
||||
if len(memory.Facts) != 2 {
|
||||
t.Fatalf("historical evidence was dropped: %+v", memory.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePersonalMemoryNormalizesOperationalSubjectMarker(t *testing.T) {
|
||||
memory := CompilePersonalMemory([]interaction.HumanUtterance{
|
||||
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
|
||||
{ID: "new", Content: "запомни: мой тестовый цвет M5 — лазурный", ReceivedAtUnixMillis: 200},
|
||||
})
|
||||
current, ok := memory.LatestPreference("тестовый цвет")
|
||||
if !ok || current.SourceUtteranceID != "new" || !strings.Contains(current.Value, "лазурный") {
|
||||
t.Fatalf("expected newest marked preference, got %+v, ok=%t", current, ok)
|
||||
}
|
||||
if len(memory.Facts) != 2 {
|
||||
t.Fatalf("expected historical fact retention, got %d facts", len(memory.Facts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,12 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
|
||||
result.Relations = append(result.Relations, relations...)
|
||||
}
|
||||
result.Reply = semanticReply(utterance.Content, result, previousFocus)
|
||||
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) && previousFocus != nil {
|
||||
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) {
|
||||
result.NeedsCognition = false
|
||||
result.Interpretation.NeedsCognition = false
|
||||
result.CapabilityHints = nil
|
||||
selectedCandidate.CapabilityHints = nil
|
||||
result.Candidates[0] = selectedCandidate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -343,8 +346,10 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
|
||||
case interaction.SpeechActRejection:
|
||||
return "Понял."
|
||||
case interaction.SpeechActQuestion:
|
||||
lower := strings.ToLower(message)
|
||||
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
|
||||
if focusIntrospectionQuestion(semanticTokens(message)) {
|
||||
if previousFocus == nil {
|
||||
return "Сейчас активный Focus не выбран."
|
||||
}
|
||||
return "Сейчас в фокусе: " + previousFocus.ProjectDisplayName + "."
|
||||
}
|
||||
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
|
||||
@@ -385,8 +390,7 @@ func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
|
||||
if act != interaction.SpeechActQuestion {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")
|
||||
return focusIntrospectionQuestion(semanticTokens(message))
|
||||
}
|
||||
|
||||
func hasCorrectionMeaning(candidate interaction.SemanticCandidate, utterance interaction.HumanUtterance) bool {
|
||||
|
||||
@@ -293,6 +293,13 @@ func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceEnglishFocusIntrospectionStaysLocal(t *testing.T) {
|
||||
result := interpretForTest(t, "Какой сейчас Focus разговора?", activeFocus("assistant", "Assistant"), "")
|
||||
if result.Reply != "Сейчас в фокусе: Assistant." || result.NeedsCognition || len(result.CapabilityHints) != 0 {
|
||||
t.Fatalf("focus introspection escaped local route: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
|
||||
result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "")
|
||||
if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package domain
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/interaction"
|
||||
|
||||
// AttributeSpeaker is an observational hint only. It deliberately cannot
|
||||
// manufacture an authenticated principal binding from voice recognition.
|
||||
func AttributeSpeaker(sourceRef, principalCandidate, observationRef string, confidence int64) interaction.SpeakerAttribution {
|
||||
if sourceRef == "" || observationRef == "" || confidence < 0 || confidence > 1000 {
|
||||
return interaction.SpeakerAttribution{}
|
||||
}
|
||||
return interaction.SpeakerAttribution{SourceRef: sourceRef, PrincipalCandidate: principalCandidate, ConfidenceMillis: confidence, ObservationRef: observationRef, Authenticated: false}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user