Compare commits
314
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4b81e320 | ||
|
|
389dd0c23d | ||
|
|
064a650763 | ||
|
|
6ecb431ccd | ||
|
|
b29f8775e1 | ||
|
|
85ee05e291 | ||
|
|
35aa64546b | ||
|
|
6e0d1a2279 | ||
|
|
64642f4bae | ||
|
|
2f9e9bdc9f | ||
|
|
2c7a85779b | ||
|
|
b4c3027e6d | ||
|
|
503fe84074 | ||
|
|
3b551b2918 | ||
|
|
51427a246e | ||
|
|
9b5ce2a850 | ||
|
|
8f3920519b | ||
|
|
2277f8a699 | ||
|
|
205d8f6206 | ||
|
|
ae06d33ccc | ||
|
|
c7b57daaf8 | ||
|
|
8aa0123047 | ||
|
|
4a3d348e39 | ||
|
|
4086b75224 | ||
|
|
4f89c46545 | ||
|
|
5d5afc8888 | ||
|
|
79ce5a0b82 | ||
|
|
65a60d5f06 | ||
|
|
4739a22f7b | ||
|
|
c25a6346d0 | ||
|
|
5f71fa8dc4 | ||
|
|
bda71caece | ||
|
|
0656f88fd0 | ||
|
|
3cf44ada8a | ||
|
|
ca00377bec | ||
|
|
ada2da0c6c | ||
|
|
82df14bde9 | ||
|
|
e1186757bf | ||
|
|
1f002bd1a3 | ||
|
|
3a52c18bef | ||
|
|
fe0351edae | ||
|
|
077f16e50c | ||
|
|
3a9d3f0503 | ||
|
|
03c3484ee1 | ||
|
|
e47d363145 | ||
|
|
5703ab3ed5 | ||
|
|
d03e9d3307 | ||
|
|
283f5c044c | ||
|
|
ea5327799a | ||
|
|
dbbc54171d | ||
|
|
2b98501a68 | ||
|
|
d082005b37 | ||
|
|
624c41d4f6 | ||
|
|
6d1d68e406 | ||
|
|
6871e36657 | ||
|
|
b1ccc3200b | ||
|
|
ee678db42c | ||
|
|
b16c12068e | ||
|
|
eb920bb9f2 | ||
|
|
dd7fd2cd68 | ||
|
|
7030a51b69 | ||
|
|
6f71b73ac2 | ||
|
|
641a8cd8fb | ||
|
|
70007c0da0 | ||
|
|
0e62ab7684 | ||
|
|
6f7421167b | ||
|
|
122dab2a9d | ||
|
|
7896404b6d | ||
|
|
a232d88bcf | ||
|
|
e023491e8c | ||
|
|
798b124fd4 | ||
|
|
c7fc8d6a63 | ||
|
|
91e867f468 | ||
|
|
785441f2a0 | ||
|
|
16d714238e | ||
|
|
7e703fe4b4 | ||
|
|
e935aadc94 | ||
|
|
7b06cd64a2 | ||
|
|
b3afe5236f | ||
|
|
cef7bd0226 | ||
|
|
23b04aafd0 | ||
|
|
6ee00af54b | ||
|
|
6f37194724 | ||
|
|
82a0052470 | ||
|
|
d563db9e57 | ||
|
|
c1bc9c35d1 | ||
|
|
5a8a368153 | ||
|
|
7a14973e9a | ||
|
|
f9e21737a5 | ||
|
|
53f86e4176 | ||
|
|
0b321fe115 | ||
|
|
94ae8589ce | ||
|
|
8d8f7cb097 | ||
|
|
6e9111dd0c | ||
|
|
8c5386ec34 | ||
|
|
38cff16f45 | ||
|
|
2d6baef7a9 | ||
|
|
89924a417a | ||
|
|
6735bca317 | ||
|
|
3c83e2ba5c | ||
|
|
f2cc059e4b | ||
|
|
91c60b8c0c | ||
|
|
1687dad063 | ||
|
|
37560816aa | ||
|
|
f92406da68 | ||
|
|
0131a96de8 | ||
|
|
0d4e9ee1b4 | ||
|
|
9282fc5bf6 | ||
|
|
4c4a85aec6 | ||
|
|
ebcae4cc9c | ||
|
|
9b262609b7 | ||
|
|
79c99289e7 | ||
|
|
07a8de9cb5 | ||
|
|
fdf38196f0 | ||
|
|
b753761e1a | ||
|
|
5faa5321dd | ||
|
|
7271d7950d | ||
|
|
533243da25 | ||
|
|
b1fba3ccef | ||
|
|
b0afa2417f | ||
|
|
6f5f7e1ee3 | ||
|
|
94c5bcb017 | ||
|
|
3f7c4705fe | ||
|
|
1ccd43c552 | ||
|
|
d38406fbc5 | ||
|
|
f73c69960c | ||
|
|
32e33e3050 | ||
|
|
e9bb42cd07 | ||
|
|
4a800df1e1 | ||
|
|
b2f922cbbc | ||
|
|
a4450348a1 | ||
|
|
323a67a7d5 | ||
|
|
bab96b7ede | ||
|
|
1b091a50f2 | ||
|
|
790fd7e768 | ||
|
|
875286b9fc | ||
|
|
8e26826ae2 | ||
|
|
c30e0c72ea | ||
|
|
d2338b1485 | ||
|
|
7f82240a8f | ||
|
|
fa62322ed2 | ||
|
|
8081b4de34 | ||
|
|
f792d208ef | ||
|
|
04dad79af1 | ||
|
|
02b472b627 | ||
|
|
a6eb4abc91 | ||
|
|
9766e5396e | ||
|
|
cb92f95550 | ||
|
|
e83cf3582b | ||
|
|
a65ee372a9 | ||
|
|
1f01b7013c | ||
|
|
93bc4a0fdf | ||
|
|
aec63de0c7 | ||
|
|
dc7f7b3b2b | ||
|
|
e3b55c9705 | ||
|
|
879de3bd95 | ||
|
|
3e0be6245e | ||
|
|
08deab8dda | ||
|
|
82ee6e5ba0 | ||
|
|
339f6a6770 | ||
|
|
6fb9184d14 | ||
|
|
3f2b008109 | ||
|
|
470fdf8c03 | ||
|
|
15c9517d0c | ||
|
|
8687c9b792 | ||
|
|
ff33a62edd | ||
|
|
b439a813e5 | ||
|
|
39d2d79c51 | ||
|
|
305a55a948 | ||
|
|
13a3446dd0 | ||
|
|
8c2989f2c6 | ||
|
|
b097269df4 | ||
|
|
39f9081b96 | ||
|
|
3de01f8a94 | ||
|
|
948896cd45 | ||
|
|
545e855298 | ||
|
|
6f8c4e2bc7 | ||
|
|
947d9d78a1 | ||
|
|
8a3b085855 | ||
|
|
cf66cd472c | ||
|
|
01f4fa27c2 | ||
|
|
d286fbb234 | ||
|
|
3b4ce66df0 | ||
|
|
2306c8d6b3 | ||
|
|
2b8887b3b2 | ||
|
|
4750dd859c | ||
|
|
a6059cc9c6 | ||
|
|
498bc885c3 | ||
|
|
487f7b7c9d | ||
|
|
b1b52920ab | ||
|
|
57fb572d5b | ||
|
|
0849e9c4aa | ||
|
|
51fa9ea506 | ||
|
|
2cf1fb5c5e | ||
|
|
4f06720e3f | ||
|
|
cdc8eea1a7 | ||
|
|
9f717d720e | ||
|
|
3b5c0ff56d | ||
|
|
9cbf6c7536 | ||
|
|
9650eefcb4 | ||
|
|
1ed28ef77a | ||
|
|
c56610fa75 | ||
|
|
3d7836d37c | ||
|
|
0482e6ac14 | ||
|
|
526368be7e | ||
|
|
3c2d8a38d7 | ||
|
|
cabf386203 | ||
|
|
f9bd267c04 | ||
|
|
b041a94f71 | ||
|
|
814c9e1d32 | ||
|
|
b93715867d | ||
|
|
009314e930 | ||
|
|
0954c8f4ef | ||
|
|
0a2599cfa6 | ||
|
|
ae9933ee84 | ||
|
|
7a59abc608 | ||
|
|
6e0cf3cd1d | ||
|
|
d7e77ea3dd | ||
|
|
47fd0c4b02 | ||
|
|
df9181ef6a | ||
|
|
b0d31012da | ||
|
|
8d149b2b9d | ||
|
|
0d10e6fd82 | ||
|
|
9aa5771f55 | ||
|
|
75abfb204b | ||
|
|
b0ee54153b | ||
|
|
26cf85108a | ||
|
|
7eb2f5f313 | ||
|
|
39a27102c9 | ||
|
|
7ac9a2697c | ||
|
|
f2e8ec08c5 | ||
|
|
1cfbb4ae66 | ||
|
|
7618a0bcbe | ||
|
|
dcdc9a2e64 | ||
|
|
5783faf9dc | ||
|
|
9a968fe2f6 | ||
|
|
41d4d7202f | ||
|
|
ed3932c57a | ||
|
|
3367361483 | ||
|
|
51638d6e8c | ||
|
|
0a1ad9f7d5 | ||
|
|
4d51e65226 | ||
|
|
11a6a23f6e | ||
|
|
ce666bbc43 | ||
|
|
64870798b9 | ||
|
|
21e995432d | ||
|
|
3e99edd3a8 | ||
|
|
c1aff60443 | ||
|
|
3aa981bc59 | ||
|
|
7759d8257e | ||
|
|
ade274011f | ||
|
|
c232169270 | ||
|
|
4352e00491 | ||
|
|
21830a03e4 | ||
|
|
4428e166be | ||
|
|
a08c187284 | ||
|
|
41b0ac196e | ||
|
|
5cf566dccd | ||
|
|
b3491520bd | ||
|
|
d870e1361d | ||
|
|
8131d6cef3 | ||
|
|
4bfca4fe24 | ||
|
|
21239cf3cc | ||
|
|
75857a2036 | ||
|
|
9a9307af89 | ||
|
|
ff66e8dc5c | ||
|
|
a862a07ae1 | ||
|
|
4d63c1bea4 | ||
|
|
91c38de167 | ||
|
|
926afc82b5 | ||
|
|
4a066d71f4 | ||
|
|
7fc611ed08 | ||
|
|
924d8c3767 | ||
|
|
67ff1f73cd | ||
|
|
0ad247bae3 | ||
|
|
2de2885f2f | ||
|
|
069c3837f3 | ||
|
|
8a88565772 | ||
|
|
e7b32be1e0 | ||
|
|
d4640d7617 | ||
|
|
29b20bc5b0 | ||
|
|
071adb5ecb | ||
|
|
465a90b25d | ||
|
|
95d6ad2d6a | ||
|
|
1e554f6758 | ||
|
|
1c7d512710 | ||
|
|
8c6a7bebbd | ||
|
|
36774b37ae | ||
|
|
c79de1c757 | ||
|
|
acf012720f | ||
|
|
ccf790d130 | ||
|
|
54c2cc27a3 | ||
|
|
8b9b841645 | ||
|
|
e8e51ffdba | ||
|
|
aa01e8e99b | ||
|
|
1113ced623 | ||
|
|
f3ff430812 | ||
|
|
2ec53e40cf | ||
|
|
18fbc7f105 | ||
|
|
a0dce634f3 | ||
|
|
5d4e064a94 | ||
|
|
6e73318e49 | ||
|
|
1519af3310 | ||
|
|
8fe596264e | ||
|
|
ee51789ee2 | ||
|
|
a083bac65f | ||
|
|
9a84ac942c | ||
|
|
fe531ab56f | ||
|
|
d8f994e07e | ||
|
|
9d50a3ec63 | ||
|
|
2d142cdfdb | ||
|
|
06b6cc3f9d | ||
|
|
4fc85511c4 | ||
|
|
18750c2ae6 |
@@ -15,6 +15,8 @@ jobs:
|
||||
go-version: '1.26.6'
|
||||
- name: Active task preflight
|
||||
run: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json
|
||||
- name: Independent governance verifier
|
||||
run: go run ./cmd/devctl/independent
|
||||
- name: Exact diff scope
|
||||
run: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json
|
||||
- name: Format check
|
||||
@@ -25,3 +27,5 @@ jobs:
|
||||
run: go test ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Diff whitespace check
|
||||
run: git diff --check
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package agentbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("agent execution bridge unavailable")
|
||||
|
||||
type HTTPExecutor struct {
|
||||
URL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPExecutor(url string, client *http.Client) (*HTTPExecutor, error) {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" || !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &HTTPExecutor{URL: strings.TrimRight(url, "/"), Client: client}, nil
|
||||
}
|
||||
|
||||
func (e *HTTPExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, e.URL+"/v1/execute", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := e.Client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("%w: status=%s", ErrUnavailable, response.Status)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 1<<20))
|
||||
var result work.AgentExecutionResult
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if result.State == "" || result.Summary == "" || result.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, errors.New("agent execution bridge returned invalid result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package agentbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestHTTPExecutorCarriesTypedDispatchAndDecodesResult(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/execute" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request work.AgentExecutionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.WorkerID != "worker-1" || request.FencingToken != 7 || request.Intent.DispatchID != "dispatch-1" {
|
||||
t.Fatalf("request=%+v", request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "completed", ObservedAtUnixMillis: 2})
|
||||
}))
|
||||
defer server.Close()
|
||||
executor, err := NewHTTPExecutor(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}, WorkerID: "worker-1", FencingToken: 7})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.Summary != "completed" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPExecutorRejectsUnavailableEndpoint(t *testing.T) {
|
||||
if _, err := NewHTTPExecutor("", nil); err != ErrUnavailable {
|
||||
t.Fatalf("expected unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,35 @@ import (
|
||||
type OpenAICompatibleLocalAdapter struct {
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
|
||||
}
|
||||
|
||||
// NewOpenAICompatibleLocalAdapterWithAPIKey creates an adapter for a gateway
|
||||
// whose bearer credential was resolved by the trusted runtime boundary.
|
||||
func NewOpenAICompatibleLocalAdapterWithAPIKey(raw, apiKey string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey, transport, 10*time.Second)
|
||||
}
|
||||
|
||||
// NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout creates a local gateway
|
||||
// adapter with an explicit invocation deadline. The request context remains
|
||||
// authoritative for per-call cancellation; this client deadline prevents a
|
||||
// provider from holding a worker indefinitely.
|
||||
func NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey string, transport http.RoundTripper, timeout time.Duration) (*OpenAICompatibleLocalAdapter, error) {
|
||||
base, err := url.Parse(raw)
|
||||
if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil {
|
||||
return nil, errors.New("invalid local compatible endpoint")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("local compatible timeout must be positive")
|
||||
}
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil
|
||||
}
|
||||
|
||||
type compatibleModelsResponse struct {
|
||||
@@ -145,6 +163,9 @@ func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method,
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if a.apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
}
|
||||
if input != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type captureRoundTripper struct {
|
||||
authorization string
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adapter.client.Timeout != 47*time.Second {
|
||||
t.Fatalf("unexpected client timeout: %s", adapter.client.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterRejectsNonPositiveTimeout(t *testing.T) {
|
||||
if _, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", nil, 0); err == nil {
|
||||
t.Fatal("expected non-positive timeout to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *captureRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
t.authorization = request.Header.Get("Authorization")
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), Header: make(http.Header), Request: request}, nil
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesResolvedAPIKey(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKey("http://gateway/v1", "resolved-key", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = adapter.Discover(context.Background())
|
||||
if transport.authorization != "Bearer resolved-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", transport.authorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterDoesNotSendAuthorizationWithoutKey(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = adapter.Discover(context.Background())
|
||||
if transport.authorization != "" {
|
||||
t.Fatalf("unexpected authorization header")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type AuthorityRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenAuthority(ctx context.Context, c ContinuityConfig) (*AuthorityRepository, error) {
|
||||
p, e := pgxpool.New(ctx, fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", c.Host, c.Port, c.Database, c.User, c.Password))
|
||||
p, e := openPool(ctx, c)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
var ErrCapabilityHealthNotFound = errors.New("capability health not found")
|
||||
var ErrCapabilityBindingNotFound = errors.New("capability binding not found")
|
||||
var ErrCapabilityBindingConflict = errors.New("capability binding conflict")
|
||||
var ErrCapabilityImplementationNotFound = errors.New("capability implementation not found")
|
||||
|
||||
type CapabilityRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenCapability(ctx context.Context, config ContinuityConfig) (*CapabilityRepository, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", config.Host, config.Port, config.Database, config.User, config.Password)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,6 +28,89 @@ func OpenCapability(ctx context.Context, config ContinuityConfig) (*CapabilityRe
|
||||
|
||||
func (r *CapabilityRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *CapabilityRepository) AdmitCognitive(ctx context.Context, leaseID string, policy capability.AdmissionPolicy, request capability.AdmissionRequest, nowUnixMillis int64) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if leaseID == "" || nowUnixMillis < 1 {
|
||||
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonInputBudget, PolicyID: policy.ID}, capability.AdmissionLease{}, nil
|
||||
}
|
||||
var existingState string
|
||||
var existingPolicy string
|
||||
err = tx.QueryRow(ctx, `SELECT state, policy_id FROM capability.cognitive_admission_leases WHERE lease_id=$1`, leaseID).Scan(&existingState, &existingPolicy)
|
||||
if err == nil {
|
||||
if existingState == "ACTIVE" {
|
||||
return capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: existingPolicy}, capability.AdmissionLease{ID: leaseID, PolicyID: existingPolicy}, tx.Commit(ctx)
|
||||
}
|
||||
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: existingPolicy}, capability.AdmissionLease{}, tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
windowStart := nowUnixMillis - 60*60*1000
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_counters(policy_id,window_start_unix_millis,accepted_count,concurrent_count,updated_at_unix_millis) VALUES($1,$2,0,0,$3) ON CONFLICT(policy_id) DO NOTHING`, policy.ID, windowStart, nowUnixMillis)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
var storedWindow int64
|
||||
var accepted, concurrent int
|
||||
if err = tx.QueryRow(ctx, `SELECT window_start_unix_millis,accepted_count,concurrent_count FROM capability.cognitive_admission_counters WHERE policy_id=$1 FOR UPDATE`, policy.ID).Scan(&storedWindow, &accepted, &concurrent); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
if nowUnixMillis-storedWindow >= 60*60*1000 {
|
||||
storedWindow, accepted = windowStart, 0
|
||||
}
|
||||
var expired int64
|
||||
if err = tx.QueryRow(ctx, `WITH expired AS (UPDATE capability.cognitive_admission_leases SET state='EXPIRED' WHERE policy_id=$1 AND state='ACTIVE' AND expires_at_unix_millis <= $2 RETURNING lease_id) SELECT count(*) FROM expired`, policy.ID, nowUnixMillis).Scan(&expired); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
concurrent -= int(expired)
|
||||
if concurrent < 0 {
|
||||
concurrent = 0
|
||||
}
|
||||
decision := capability.Admit(request, policy, capability.AdmissionCounters{Concurrent: concurrent, AcceptedLastHour: accepted}, nowUnixMillis)
|
||||
if decision.State != capability.AdmissionAdmitted {
|
||||
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted, concurrent, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return decision, capability.AdmissionLease{}, tx.Commit(ctx)
|
||||
}
|
||||
expires := nowUnixMillis + 60*1000
|
||||
if request.DeadlineMillis > nowUnixMillis && request.DeadlineMillis < expires {
|
||||
expires = request.DeadlineMillis
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted+1, concurrent+1, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_leases(lease_id,policy_id,state,expires_at_unix_millis,created_at_unix_millis) VALUES($1,$2,'ACTIVE',$3,$4)`, leaseID, policy.ID, expires, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return decision, capability.AdmissionLease{ID: leaseID, PolicyID: policy.ID}, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) ReleaseCognitiveAdmission(ctx context.Context, lease capability.AdmissionLease, nowUnixMillis int64) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var policyID string
|
||||
err = tx.QueryRow(ctx, `UPDATE capability.cognitive_admission_leases SET state='RELEASED' WHERE lease_id=$1 AND state='ACTIVE' RETURNING policy_id`, lease.ID).Scan(&policyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET concurrent_count=GREATEST(0,concurrent_count-1),updated_at_unix_millis=$2 WHERE policy_id=$1`, policyID, nowUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveHealth(ctx context.Context, health capability.Health) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.health (capability_id, state, reason, revision, semantic_version, observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (capability_id) DO UPDATE SET state=EXCLUDED.state, reason=EXCLUDED.reason, revision=EXCLUDED.revision, semantic_version=EXCLUDED.semantic_version, observed_at_unix_millis=EXCLUDED.observed_at_unix_millis WHERE capability.health.revision < EXCLUDED.revision`, health.CapabilityID, health.State, health.Reason, health.Revision, health.SemanticVersion, health.ObservedAtUnixMillis)
|
||||
return err
|
||||
@@ -106,6 +189,192 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveCapabilityImplementation(ctx context.Context, value capability.CapabilityImplementation) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.capability_implementations (
|
||||
implementation_id,capability_id,capability_kind,owner_principal_id,revision,implementation_kind,target_ref,credential_ref,
|
||||
qualification,activation,health,authority_policy_ref,data_constraints,cost_score_millis,latency_score_millis,reliability_millis,observed_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.CapabilityID, value.CapabilityKind, value.OwnerPrincipalID, value.Revision, value.Kind, value.TargetRef,
|
||||
value.CredentialRef, value.Qualification, value.Activation, value.Health, value.AuthorityPolicyRef, value.DataConstraints,
|
||||
value.CostScoreMillis, value.LatencyScoreMillis, value.ReliabilityMillis, value.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.capability_implementation_heads(implementation_id,revision,capability_kind,owner_principal_id)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (implementation_id) DO UPDATE SET revision=EXCLUDED.revision,capability_kind=EXCLUDED.capability_kind,owner_principal_id=EXCLUDED.owner_principal_id,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE capability.capability_implementation_heads.revision < EXCLUDED.revision`, value.ID, value.Revision, value.CapabilityKind, value.OwnerPrincipalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCapabilityImplementations(ctx context.Context, kind capability.CapabilityKind, owner identity.PrincipalID) ([]capability.CapabilityImplementation, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
i.implementation_id,i.capability_id,i.capability_kind,i.owner_principal_id,i.revision,i.implementation_kind,i.target_ref,COALESCE(i.credential_ref,''),
|
||||
i.qualification,i.activation,i.health,i.authority_policy_ref,i.data_constraints,i.cost_score_millis,i.latency_score_millis,i.reliability_millis,i.observed_at_unix_millis
|
||||
FROM capability.capability_implementation_heads h
|
||||
JOIN capability.capability_implementations i ON i.implementation_id=h.implementation_id AND i.revision=h.revision
|
||||
WHERE i.capability_kind=$1 AND i.owner_principal_id=$2
|
||||
ORDER BY i.implementation_id`, kind, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]capability.CapabilityImplementation, 0)
|
||||
for rows.Next() {
|
||||
var value capability.CapabilityImplementation
|
||||
if err = rows.Scan(&value.ID, &value.CapabilityID, &value.CapabilityKind, &value.OwnerPrincipalID, &value.Revision, &value.Kind, &value.TargetRef,
|
||||
&value.CredentialRef, &value.Qualification, &value.Activation, &value.Health, &value.AuthorityPolicyRef, &value.DataConstraints,
|
||||
&value.CostScoreMillis, &value.LatencyScoreMillis, &value.ReliabilityMillis, &value.ObservedAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) AvailableCapabilityKinds(ctx context.Context, owner identity.PrincipalID) ([]capability.CapabilityKind, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT DISTINCT i.capability_kind
|
||||
FROM capability.capability_implementation_heads h
|
||||
JOIN capability.capability_implementations i ON i.implementation_id=h.implementation_id AND i.revision=h.revision
|
||||
WHERE i.owner_principal_id=$1 AND i.qualification=$2 AND i.activation=$3 AND i.health=$4
|
||||
ORDER BY i.capability_kind`, owner, capability.QualificationValidated, capability.ActivationActive, capability.HealthAvailable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]capability.CapabilityKind, 0)
|
||||
for rows.Next() {
|
||||
var kind capability.CapabilityKind
|
||||
if err := rows.Scan(&kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, kind)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveCapabilityGap(ctx context.Context, value capability.CapabilityGap) error {
|
||||
strategies := make([]string, 0, len(value.Strategies))
|
||||
for _, strategy := range value.Strategies {
|
||||
strategies = append(strategies, string(strategy.Kind))
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.capability_gaps (
|
||||
gap_id,requirement_id,capability_kind,capability_id,owner_principal_id,original_intent_id,source_utterance_id,
|
||||
reason,detail,strategies,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.RequirementID,
|
||||
value.CapabilityKind, value.CapabilityID, value.OwnerPrincipalID, value.OriginalIntentID, value.SourceUtteranceID,
|
||||
value.Reason, value.Detail, strategies, value.RecordedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LatestCapabilityGap(ctx context.Context, kind capability.CapabilityKind, owner identity.PrincipalID) (capability.CapabilityGap, error) {
|
||||
var value capability.CapabilityGap
|
||||
var strategies []string
|
||||
err := r.pool.QueryRow(ctx, `SELECT gap_id,requirement_id,capability_kind,capability_id,owner_principal_id,original_intent_id,source_utterance_id,reason,detail,strategies,recorded_at_unix_millis
|
||||
FROM capability.capability_gaps WHERE capability_kind=$1 AND owner_principal_id=$2 ORDER BY recorded_at_unix_millis DESC, gap_id DESC LIMIT 1`, kind, owner).
|
||||
Scan(&value.ID, &value.RequirementID, &value.CapabilityKind, &value.CapabilityID, &value.OwnerPrincipalID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Reason, &value.Detail, &strategies, &value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return capability.CapabilityGap{}, err
|
||||
}
|
||||
for _, strategy := range strategies {
|
||||
value.Strategies = append(value.Strategies, capability.AcquisitionStrategyCandidate{Kind: capability.AcquisitionStrategyKind(strategy)})
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveCapabilityUsageObservation(ctx context.Context, value capability.CapabilityUsageObservation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.capability_usage_observations (
|
||||
observation_id,implementation_id,implementation_revision,capability_id,capability_kind,owner_principal_id,
|
||||
original_intent_id,source_utterance_id,outcome,outcome_ref,latency_millis,observed_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, value.ID, value.ImplementationID,
|
||||
value.ImplementationRevision, value.CapabilityID, value.CapabilityKind, value.OwnerPrincipalID, value.OriginalIntentID,
|
||||
value.SourceUtteranceID, value.Outcome, value.OutcomeRef, value.LatencyMillis, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveSkillProfile(ctx context.Context, value capability.SkillProfile) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.skill_profiles (
|
||||
skill_profile_id,revision,name,description,applicable_context,implementation_ref,implementation_version,status,
|
||||
supporting_experience_refs,known_failure_experience_refs,confidence_millis,last_validated_unix_millis,
|
||||
observed_failure_count,observed_correction_count,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.Revision, value.Name, value.Description, value.ApplicableContext, value.ImplementationRef,
|
||||
value.ImplementationVersion, value.Status, value.SupportingExperienceRefs, value.KnownFailureExperienceRefs,
|
||||
value.ConfidenceMillis, value.LastValidatedUnixMillis, value.ObservedFailureCount, value.ObservedCorrectionCount,
|
||||
value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.skill_profile_heads(skill_profile_id,revision,status)
|
||||
VALUES ($1,$2,$3)
|
||||
ON CONFLICT (skill_profile_id) DO UPDATE SET revision=EXCLUDED.revision,status=EXCLUDED.status,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE capability.skill_profile_heads.revision < EXCLUDED.revision`, value.ID, value.Revision, value.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadSkillProfile(ctx context.Context, id capability.SkillProfileID) (capability.SkillProfile, error) {
|
||||
var value capability.SkillProfile
|
||||
err := r.pool.QueryRow(ctx, `SELECT
|
||||
p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status,
|
||||
p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis,
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis
|
||||
FROM capability.skill_profile_heads h
|
||||
JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision
|
||||
WHERE p.skill_profile_id=$1`, id).Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext,
|
||||
&value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs,
|
||||
&value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis,
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return capability.SkillProfile{}, fmt.Errorf("load skill profile: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) RecentSkillProfiles(ctx context.Context, limit int) ([]capability.SkillProfile, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid skill profile limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status,
|
||||
p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis,
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis
|
||||
FROM capability.skill_profile_heads h
|
||||
JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision
|
||||
ORDER BY h.updated_at DESC, p.skill_profile_id LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []capability.SkillProfile
|
||||
for rows.Next() {
|
||||
var value capability.SkillProfile
|
||||
if err := rows.Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext,
|
||||
&value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs,
|
||||
&value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis,
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) {
|
||||
var binding capability.Binding
|
||||
err := r.pool.QueryRow(ctx, `SELECT binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path,
|
||||
@@ -136,18 +405,18 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability
|
||||
|
||||
func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances
|
||||
(provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind,
|
||||
value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis)
|
||||
(provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind,
|
||||
value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.BillingMode, value.AuthMode, value.InputCostMicros, value.OutputCostMicros, value.RequestCostMicros)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveModelDescriptor(ctx context.Context, value capability.ModelDescriptor) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors
|
||||
(model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.Revision,
|
||||
(model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, value.ID, value.Revision,
|
||||
value.ProviderInstanceID, value.ProviderModelID, value.Fingerprint, value.MaxContextBytes, value.Modalities,
|
||||
value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis)
|
||||
value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.IntelligenceScoreMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -163,8 +432,8 @@ func (r *CapabilityRepository) SaveCognitiveBinding(ctx context.Context, value c
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref,
|
||||
p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),
|
||||
m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0))
|
||||
p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),p.billing_mode,p.auth_mode,p.input_cost_micros,p.output_cost_micros,p.request_cost_micros,
|
||||
m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0)),m.intelligence_score_millis
|
||||
FROM capability.cognitive_binding_revision_heads bh
|
||||
JOIN capability.cognitive_bindings b ON b.binding_id=bh.binding_id AND b.revision=bh.revision
|
||||
JOIN capability.cognitive_provider_instances p ON p.provider_instance_id=b.provider_instance_id AND p.revision=b.provider_revision
|
||||
@@ -180,8 +449,8 @@ func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capa
|
||||
for rows.Next() {
|
||||
var value capability.ResolvedCognitiveBinding
|
||||
err = rows.Scan(&value.Binding.ID, &value.Binding.Revision, &value.Binding.CapabilityID, &value.Binding.OwnerPrincipalID, &value.Binding.ProviderInstanceID, &value.Binding.ProviderRevision, &value.Binding.ModelDescriptorID, &value.Binding.ModelRevision, &value.Binding.DataPolicy, &value.Binding.Qualification, &value.Binding.Activation, &value.Binding.Health, &value.Binding.LimitPolicyRef,
|
||||
&value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis,
|
||||
&value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis)
|
||||
&value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis, &value.Provider.BillingMode, &value.Provider.AuthMode, &value.Provider.InputCostMicros, &value.Provider.OutputCostMicros, &value.Provider.RequestCostMicros,
|
||||
&value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis, &value.Model.IntelligenceScoreMillis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,7 +497,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (provider_instance_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,billing_mode=EXCLUDED.billing_mode,auth_mode=EXCLUDED.auth_mode,input_cost_micros=EXCLUDED.input_cost_micros,output_cost_micros=EXCLUDED.output_cost_micros,request_cost_micros=EXCLUDED.request_cost_micros WHERE capability.cognitive_provider_instances.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis, value.Provider.BillingMode, value.Provider.AuthMode, value.Provider.InputCostMicros, value.Provider.OutputCostMicros, value.Provider.RequestCostMicros)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -237,7 +506,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
return err
|
||||
}
|
||||
for index, model := range value.Models {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (model_descriptor_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,intelligence_score_millis=EXCLUDED.intelligence_score_millis WHERE capability.cognitive_model_descriptors.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis, model.IntelligenceScoreMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -247,7 +516,11 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
return err
|
||||
}
|
||||
binding := value.Bindings[index]
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (binding_id,revision) DO UPDATE SET health=EXCLUDED.health WHERE capability.cognitive_bindings.health <> EXCLUDED.health`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -267,10 +540,10 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context,
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
p, m, b, e := value.Provider, value.Model, value.Binding, value.Evaluation
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis); err != nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis, p.BillingMode, p.AuthMode, p.InputCostMicros, p.OutputCostMicros, p.RequestCostMicros); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis); err != nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis, m.IntelligenceScoreMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil {
|
||||
@@ -289,6 +562,32 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context,
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveCognitiveActivationPromotion(ctx context.Context, value capability.CognitiveActivationPromotion) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
b, e := value.Binding, value.Evaluation
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_evaluated_capabilities (evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, e.ID, e.BindingID, e.BindingRevision, e.CapabilityID, e.Result, e.QualityScoreMillis, e.CriticalFailureMillis, e.BenchmarkVersion, e.ArtifactID, e.ObservedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_bindings SET activation='DEPRECATED' WHERE capability_id=$1 AND owner_principal_id=$2 AND activation='ACTIVE' AND NOT (binding_id=$3 AND revision=$4)`, b.CapabilityID, b.OwnerPrincipalID, b.ID, b.Revision); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.Exec(ctx, `UPDATE capability.cognitive_binding_revision_heads SET revision=$1,updated_at=CURRENT_TIMESTAMP WHERE binding_id=$2 AND revision=$3`, b.Revision, b.ID, value.ExpectedBindingRevision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrCapabilityBindingConflict
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SavePerformanceEvidence(ctx context.Context, value capability.PerformanceEvidence) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_performance_evidence (evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.ModelDescriptorID, value.ModelRevision, value.HardwareProfile, value.Runtime, value.Quantization, value.ContextUnits, value.ThinkingEnabled, value.GenerationTPSMillis, value.PromptTPSMillis, value.TTFTMicros, value.LatencyMicros, value.MemoryBytes, value.ReliabilityMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
|
||||
@@ -23,7 +23,7 @@ var (
|
||||
type CommitRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenCommit(ctx context.Context, c ContinuityConfig) (*CommitRepository, error) {
|
||||
p, e := pgxpool.New(ctx, fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", c.Host, c.Port, c.Database, c.User, c.Password))
|
||||
p, e := openPool(ctx, c)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -19,14 +22,33 @@ var (
|
||||
ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict")
|
||||
ErrActionReceiptNotFound = errors.New("continuity action receipt not found")
|
||||
ErrActionIntentConflict = errors.New("action intent was already used for a different command")
|
||||
ErrAttentionNotFound = errors.New("attention item not found")
|
||||
ErrAttentionStateConflict = errors.New("attention item state conflict")
|
||||
ErrAttentionRevisionConflict = errors.New("attention item revision conflict")
|
||||
)
|
||||
|
||||
type ContinuityConfig struct{ Host, Port, Database, User, Password string }
|
||||
type ContinuityRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func openPool(ctx context.Context, config ContinuityConfig) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.ParseUint(config.Port, 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid postgres port: %w", err)
|
||||
}
|
||||
poolConfig.ConnConfig.Host = config.Host
|
||||
poolConfig.ConnConfig.Port = uint16(port)
|
||||
poolConfig.ConnConfig.Database = config.Database
|
||||
poolConfig.ConnConfig.User = config.User
|
||||
poolConfig.ConnConfig.Password = config.Password
|
||||
return pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
}
|
||||
|
||||
func OpenContinuity(ctx context.Context, config ContinuityConfig) (*ContinuityRepository, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", config.Host, config.Port, config.Database, config.User, config.Password)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,12 +56,12 @@ func OpenContinuity(ctx context.Context, config ContinuityConfig) (*ContinuityRe
|
||||
}
|
||||
func (r *ContinuityRepository) Close() { r.pool.Close() }
|
||||
func (r *ContinuityRepository) Save(ctx context.Context, item continuity.ProspectiveItem) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO continuity.prospective_items (prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (prospective_item_id) DO UPDATE SET state=EXCLUDED.state, revision=EXCLUDED.revision`, item.ID, item.CommitmentID, item.OwnerPrincipalID, item.ScopeID, item.GoalID, item.TriggerAtUnixMillis, item.State, item.Revision)
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO continuity.prospective_items (prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (prospective_item_id) DO UPDATE SET state=EXCLUDED.state, revision=EXCLUDED.revision`, item.ID, item.CommitmentID, item.OwnerPrincipalID, item.ScopeID, item.GoalID, item.SourceIntentID, item.SourceUtteranceID, item.Summary, item.TriggerAtUnixMillis, item.State, item.Revision)
|
||||
return err
|
||||
}
|
||||
func (r *ContinuityRepository) Load(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
|
||||
var item continuity.ProspectiveItem
|
||||
err := r.pool.QueryRow(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE prospective_item_id=$1`, id).Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.TriggerAtUnixMillis, &item.State, &item.Revision)
|
||||
err := r.pool.QueryRow(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE prospective_item_id=$1`, id).Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, fmt.Errorf("load prospective item: %w", err)
|
||||
}
|
||||
@@ -47,7 +69,7 @@ func (r *ContinuityRepository) Load(ctx context.Context, id continuity.Prospecti
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.ProspectiveItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE owner_principal_id=$1 ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $2`, owner, limit)
|
||||
rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision FROM continuity.prospective_items WHERE owner_principal_id=$1 ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $2`, owner, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list prospective items by owner: %w", err)
|
||||
}
|
||||
@@ -55,7 +77,7 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ
|
||||
items := make([]continuity.ProspectiveItem, 0)
|
||||
for rows.Next() {
|
||||
var item continuity.ProspectiveItem
|
||||
if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan prospective item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -66,6 +88,477 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) DueArmed(ctx context.Context, nowUnixMillis int64, limit int) ([]continuity.ProspectiveItem, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid due reminder limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision
|
||||
FROM continuity.prospective_items
|
||||
WHERE state=$1 AND trigger_at_unix_millis <= $2
|
||||
ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $3`, continuity.ProspectiveArmed, nowUnixMillis, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due prospective items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]continuity.ProspectiveItem, 0)
|
||||
for rows.Next() {
|
||||
var item continuity.ProspectiveItem
|
||||
if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan due prospective item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ClaimDueReminder(ctx context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, nowUnixMillis int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var triggered continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 AND trigger_at_unix_millis <= $5
|
||||
RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
|
||||
continuity.ProspectiveTriggered, id, continuity.ProspectiveArmed, revision, nowUnixMillis).Scan(
|
||||
&triggered.ID, &triggered.CommitmentID, &triggered.OwnerPrincipalID, &triggered.ScopeID, &triggered.GoalID,
|
||||
&triggered.SourceIntentID, &triggered.SourceUtteranceID, &triggered.Summary, &triggered.TriggerAtUnixMillis, &triggered.State, &triggered.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, ErrProspectiveRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("trigger due reminder: %w", err)
|
||||
}
|
||||
|
||||
var revalidating continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
|
||||
RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
|
||||
continuity.ProspectiveRevalidating, triggered.ID, continuity.ProspectiveTriggered, triggered.Revision).Scan(
|
||||
&revalidating.ID, &revalidating.CommitmentID, &revalidating.OwnerPrincipalID, &revalidating.ScopeID, &revalidating.GoalID,
|
||||
&revalidating.SourceIntentID, &revalidating.SourceUtteranceID, &revalidating.Summary, &revalidating.TriggerAtUnixMillis, &revalidating.State, &revalidating.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("begin due reminder revalidation: %w", err)
|
||||
}
|
||||
|
||||
var delivered continuity.ProspectiveItem
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
|
||||
RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
|
||||
continuity.ProspectiveDelivered, revalidating.ID, continuity.ProspectiveRevalidating, revalidating.Revision).Scan(
|
||||
&delivered.ID, &delivered.CommitmentID, &delivered.OwnerPrincipalID, &delivered.ScopeID, &delivered.GoalID,
|
||||
&delivered.SourceIntentID, &delivered.SourceUtteranceID, &delivered.Summary, &delivered.TriggerAtUnixMillis, &delivered.State, &delivered.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("finish due reminder revalidation: %w", err)
|
||||
}
|
||||
|
||||
attention, err := continuitydomain.BuildDueReminderAttention(delivered, attentionID, nowUnixMillis)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items (
|
||||
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,
|
||||
action_attempt_id,delivery_outcome,response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision)
|
||||
VALUES ($1,$2,$3,$4,$5,'',$6,$7,$8,$9,$10,0,'','','','','','','','',0,1)
|
||||
ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET
|
||||
state=EXCLUDED.state,
|
||||
due_at_unix_millis=EXCLUDED.due_at_unix_millis,
|
||||
created_at_unix_millis=EXCLUDED.created_at_unix_millis,
|
||||
delivered_at_unix_millis=0,
|
||||
action_intent_id='',
|
||||
authorized_action_id='',
|
||||
action_attempt_id='',
|
||||
delivery_outcome='',
|
||||
response_id='',
|
||||
response_utterance_id='',
|
||||
response_operation='',
|
||||
response_outcome='',
|
||||
responded_at_unix_millis=0,
|
||||
revision=continuity.attention_items.revision+1
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID,
|
||||
attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan(
|
||||
&attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID, &attention.SourceExternalRef,
|
||||
&attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis,
|
||||
&attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID,
|
||||
&attention.DeliveryOutcome, &attention.ResponseID, &attention.ResponseUtteranceID, &attention.ResponseOperation,
|
||||
&attention.ResponseOutcome, &attention.RespondedAtUnixMillis, &attention.Revision)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
|
||||
}
|
||||
return delivered, attention, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) PendingAttention(ctx context.Context, limit int) ([]continuity.AttentionItem, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid attention limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||
FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending attention: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttentionRows(rows)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.AttentionItem, error) {
|
||||
if owner == "" || limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid attention owner query")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||
FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list attention by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttentionRows(rows)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ResponseCandidates(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuitydomain.ResponseCandidate, error) {
|
||||
if owner == "" || limit < 1 || limit > 50 {
|
||||
return nil, fmt.Errorf("invalid response candidate query")
|
||||
}
|
||||
candidates := make([]continuitydomain.ResponseCandidate, 0, limit)
|
||||
attentionRows, err := r.pool.Query(ctx, `SELECT
|
||||
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision
|
||||
FROM continuity.attention_items
|
||||
WHERE owner_principal_id=$1
|
||||
AND source_kind=$2
|
||||
AND state IN ($3,$4,$5)
|
||||
ORDER BY created_at_unix_millis DESC, attention_item_id DESC
|
||||
LIMIT $6`,
|
||||
owner, continuity.AttentionSourceAgentDelegation, continuity.AttentionPending, continuity.AttentionDelivered, continuity.AttentionFailed, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list delegation response candidates: %w", err)
|
||||
}
|
||||
defer attentionRows.Close()
|
||||
for attentionRows.Next() {
|
||||
item, err := scanAttentionItem(attentionRows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan delegation response candidate: %w", err)
|
||||
}
|
||||
candidates = append(candidates, continuitydomain.ResponseCandidate{Attention: item})
|
||||
}
|
||||
if err := attentionRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list delegation response candidates: %w", err)
|
||||
}
|
||||
if len(candidates) >= limit {
|
||||
return candidates[:limit], nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
COALESCE(a.attention_item_id,''),COALESCE(a.owner_principal_id,''),COALESCE(a.scope_id,''),COALESCE(a.source_kind,''),COALESCE(a.source_prospective_item_id,''),COALESCE(a.source_external_ref,''),COALESCE(a.level,''),COALESCE(a.state,''),COALESCE(a.summary,''),
|
||||
COALESCE(a.due_at_unix_millis,0),COALESCE(a.created_at_unix_millis,0),COALESCE(a.delivered_at_unix_millis,0),COALESCE(a.action_intent_id,''),COALESCE(a.authorized_action_id,''),COALESCE(a.action_attempt_id,''),COALESCE(a.delivery_outcome,''),
|
||||
COALESCE(a.response_id,''),COALESCE(a.response_utterance_id,''),COALESCE(a.response_operation,''),COALESCE(a.response_outcome,''),COALESCE(a.responded_at_unix_millis,0),COALESCE(a.revision,0),
|
||||
p.prospective_item_id,p.commitment_id,p.owner_principal_id,p.scope_id,p.goal_id,p.source_intent_id,p.source_utterance_id,p.summary,p.trigger_at_unix_millis,p.state,p.revision
|
||||
FROM continuity.prospective_items p
|
||||
LEFT JOIN continuity.attention_items a ON a.source_kind=$2 AND a.source_prospective_item_id=p.prospective_item_id AND a.owner_principal_id=p.owner_principal_id
|
||||
WHERE p.owner_principal_id=$1
|
||||
AND (a.attention_item_id IS NULL OR a.state IN ($3,$4,$5))
|
||||
AND p.state IN ($6,$7)
|
||||
ORDER BY p.recorded_at DESC, COALESCE(a.delivered_at_unix_millis,0) DESC, p.prospective_item_id DESC
|
||||
LIMIT $8`,
|
||||
owner, continuity.AttentionSourceProspectiveReminder, continuity.AttentionDelivered, continuity.AttentionPending,
|
||||
continuity.AttentionFailed, continuity.ProspectiveDelivered, continuity.ProspectiveArmed, limit-len(candidates))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var candidate continuitydomain.ResponseCandidate
|
||||
var attentionID, attentionOwner, attentionScope, sourceKind, sourceProspective, sourceExternalRef, level, attentionState, attentionSummary string
|
||||
var actionIntent, authorizedAction, actionAttempt, deliveryOutcome, responseID, responseUtterance, responseOperation, responseOutcome string
|
||||
var dueAt, createdAt, deliveredAt, respondedAt int64
|
||||
var attentionRevision uint64
|
||||
if err := rows.Scan(
|
||||
&attentionID, &attentionOwner, &attentionScope, &sourceKind, &sourceProspective, &sourceExternalRef, &level, &attentionState, &attentionSummary,
|
||||
&dueAt, &createdAt, &deliveredAt, &actionIntent, &authorizedAction, &actionAttempt, &deliveryOutcome,
|
||||
&responseID, &responseUtterance, &responseOperation, &responseOutcome, &respondedAt, &attentionRevision,
|
||||
&candidate.Prospective.ID, &candidate.Prospective.CommitmentID, &candidate.Prospective.OwnerPrincipalID,
|
||||
&candidate.Prospective.ScopeID, &candidate.Prospective.GoalID, &candidate.Prospective.SourceIntentID,
|
||||
&candidate.Prospective.SourceUtteranceID, &candidate.Prospective.Summary, &candidate.Prospective.TriggerAtUnixMillis,
|
||||
&candidate.Prospective.State, &candidate.Prospective.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan human response candidate: %w", err)
|
||||
}
|
||||
if attentionID != "" {
|
||||
candidate.Attention = continuity.AttentionItem{
|
||||
ID: continuity.AttentionItemID(attentionID),
|
||||
OwnerPrincipalID: identity.PrincipalID(attentionOwner),
|
||||
ScopeID: authority.ScopeID(attentionScope),
|
||||
SourceKind: continuity.AttentionSourceKind(sourceKind),
|
||||
SourceProspectiveID: continuity.ProspectiveItemID(sourceProspective),
|
||||
SourceExternalRef: sourceExternalRef,
|
||||
Level: continuity.AttentionLevel(level),
|
||||
State: continuity.AttentionState(attentionState),
|
||||
Summary: attentionSummary,
|
||||
DueAtUnixMillis: dueAt,
|
||||
CreatedAtUnixMillis: createdAt,
|
||||
DeliveredAtUnixMillis: deliveredAt,
|
||||
ActionIntentID: continuity.CommittedActionIntentID(actionIntent),
|
||||
AuthorizedActionID: continuity.CommittedAuthorizedActionID(authorizedAction),
|
||||
ActionAttemptID: continuity.CommittedActionAttemptID(actionAttempt),
|
||||
DeliveryOutcome: deliveryOutcome,
|
||||
ResponseID: continuity.HumanResponseID(responseID),
|
||||
ResponseUtteranceID: responseUtterance,
|
||||
ResponseOperation: continuity.HumanResponseOperation(responseOperation),
|
||||
ResponseOutcome: continuity.ResponseOutcome(responseOutcome),
|
||||
RespondedAtUnixMillis: respondedAt,
|
||||
Revision: continuity.AttentionRevision(attentionRevision),
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list human response candidates: %w", err)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func scanAttentionItem(scanner interface {
|
||||
Scan(...any) error
|
||||
}) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
var attentionID, attentionOwner, attentionScope, sourceKind, sourceProspective, sourceExternalRef, level, attentionState, attentionSummary string
|
||||
var actionIntent, authorizedAction, actionAttempt, deliveryOutcome, responseID, responseUtterance, responseOperation, responseOutcome string
|
||||
var dueAt, createdAt, deliveredAt, respondedAt int64
|
||||
var attentionRevision uint64
|
||||
if err := scanner.Scan(
|
||||
&attentionID, &attentionOwner, &attentionScope, &sourceKind, &sourceProspective, &sourceExternalRef, &level, &attentionState, &attentionSummary,
|
||||
&dueAt, &createdAt, &deliveredAt, &actionIntent, &authorizedAction, &actionAttempt, &deliveryOutcome,
|
||||
&responseID, &responseUtterance, &responseOperation, &responseOutcome, &respondedAt, &attentionRevision); err != nil {
|
||||
return continuity.AttentionItem{}, err
|
||||
}
|
||||
item = continuity.AttentionItem{
|
||||
ID: continuity.AttentionItemID(attentionID),
|
||||
OwnerPrincipalID: identity.PrincipalID(attentionOwner),
|
||||
ScopeID: authority.ScopeID(attentionScope),
|
||||
SourceKind: continuity.AttentionSourceKind(sourceKind),
|
||||
SourceProspectiveID: continuity.ProspectiveItemID(sourceProspective),
|
||||
SourceExternalRef: sourceExternalRef,
|
||||
Level: continuity.AttentionLevel(level),
|
||||
State: continuity.AttentionState(attentionState),
|
||||
Summary: attentionSummary,
|
||||
DueAtUnixMillis: dueAt,
|
||||
CreatedAtUnixMillis: createdAt,
|
||||
DeliveredAtUnixMillis: deliveredAt,
|
||||
ActionIntentID: continuity.CommittedActionIntentID(actionIntent),
|
||||
AuthorizedActionID: continuity.CommittedAuthorizedActionID(authorizedAction),
|
||||
ActionAttemptID: continuity.CommittedActionAttemptID(actionAttempt),
|
||||
DeliveryOutcome: deliveryOutcome,
|
||||
ResponseID: continuity.HumanResponseID(responseID),
|
||||
ResponseUtteranceID: responseUtterance,
|
||||
ResponseOperation: continuity.HumanResponseOperation(responseOperation),
|
||||
ResponseOutcome: continuity.ResponseOutcome(responseOutcome),
|
||||
RespondedAtUnixMillis: respondedAt,
|
||||
Revision: continuity.AttentionRevision(attentionRevision),
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) ApplyHumanResponse(ctx context.Context, response continuity.HumanResponse) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO continuity.human_responses (
|
||||
human_response_id,owner_principal_id,scope_id,source_utterance_id,source_intent_id,operation,target_kind,
|
||||
target_attention_id,target_prospective_item_id,target_external_ref,resolution_state,outcome,clarification,
|
||||
snooze_until_unix_millis,user_reported_not_verified,recorded_at_unix_millis,revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||
response.ID, response.OwnerPrincipalID, response.ScopeID, response.SourceUtteranceID, response.SourceIntentID,
|
||||
response.Operation, response.TargetKind, response.TargetAttentionID, response.TargetProspectiveID, response.TargetExternalRef,
|
||||
response.ResolutionState, response.Outcome, response.Clarification, response.SnoozeUntilUnixMillis,
|
||||
response.UserReportedNotVerified, response.RecordedAtUnixMillis, response.Revision); err != nil {
|
||||
return fmt.Errorf("save human response: %w", err)
|
||||
}
|
||||
if response.ResolutionState != continuity.ResponseResolved {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
switch response.Operation {
|
||||
case continuity.ResponseComplete:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||
continuity.ProspectiveSatisfied, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID != "" {
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case continuity.ResponseSnooze:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, trigger_at_unix_millis=$2, revision=revision+1
|
||||
WHERE prospective_item_id=$3 AND owner_principal_id=$4 AND state IN ($5,$6)`,
|
||||
continuity.ProspectiveArmed, response.SnoozeUntilUnixMillis, response.TargetProspectiveID,
|
||||
response.OwnerPrincipalID, continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snooze response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID != "" {
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case continuity.ResponseCancel:
|
||||
tag, err := tx.Exec(ctx, `UPDATE continuity.prospective_items SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state IN ($4,$5)`,
|
||||
continuity.ProspectiveSuppressed, response.TargetProspectiveID, response.OwnerPrincipalID,
|
||||
continuity.ProspectiveDelivered, continuity.ProspectiveArmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancel response prospective: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrProspectiveStateConflict
|
||||
}
|
||||
if response.TargetAttentionID != "" {
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionSuppressed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case continuity.ResponseAcknowledge, continuity.ResponseDismiss:
|
||||
if response.TargetAttentionID == "" {
|
||||
return ErrAttentionNotFound
|
||||
}
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
case continuity.ResponseContinue, continuity.ResponseReject:
|
||||
if response.TargetAttentionID == "" {
|
||||
return ErrAttentionNotFound
|
||||
}
|
||||
if err = markAttentionResponded(ctx, tx, response, continuity.AttentionResolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) CreateAttentionItem(ctx context.Context, item continuity.AttentionItem) (continuity.AttentionItem, error) {
|
||||
err := r.pool.QueryRow(ctx, `INSERT INTO continuity.attention_items(
|
||||
attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,
|
||||
due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||
ON CONFLICT (source_kind, source_external_ref) WHERE source_external_ref <> '' DO UPDATE SET
|
||||
level=EXCLUDED.level,
|
||||
state=EXCLUDED.state,
|
||||
summary=EXCLUDED.summary,
|
||||
due_at_unix_millis=EXCLUDED.due_at_unix_millis,
|
||||
created_at_unix_millis=EXCLUDED.created_at_unix_millis,
|
||||
delivered_at_unix_millis=0,
|
||||
action_intent_id='',
|
||||
authorized_action_id='',
|
||||
action_attempt_id='',
|
||||
delivery_outcome='',
|
||||
response_id='',
|
||||
response_utterance_id='',
|
||||
response_operation='',
|
||||
response_outcome='',
|
||||
responded_at_unix_millis=0,
|
||||
revision=continuity.attention_items.revision+1
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
item.ID, item.OwnerPrincipalID, item.ScopeID, item.SourceKind, item.SourceProspectiveID, item.SourceExternalRef, item.Level, item.State,
|
||||
item.Summary, item.DueAtUnixMillis, item.CreatedAtUnixMillis, item.DeliveredAtUnixMillis, item.ActionIntentID, item.AuthorizedActionID,
|
||||
item.ActionAttemptID, item.DeliveryOutcome, item.ResponseID, item.ResponseUtteranceID, item.ResponseOperation, item.ResponseOutcome,
|
||||
item.RespondedAtUnixMillis, item.Revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.SourceExternalRef, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID,
|
||||
&item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID, &item.ResponseOperation, &item.ResponseOutcome,
|
||||
&item.RespondedAtUnixMillis, &item.Revision)
|
||||
if err != nil {
|
||||
return continuity.AttentionItem{}, fmt.Errorf("create attention item: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1
|
||||
WHERE attention_item_id=$2 AND state=$3 AND revision=$4
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.SourceExternalRef, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAtUnixMillis int64) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, revision=revision+1, delivered_at_unix_millis=$2, action_intent_id=$3, authorized_action_id=$4,
|
||||
action_attempt_id=$5, delivery_outcome=$6
|
||||
WHERE attention_item_id=$7 AND state=$8 AND revision=$9
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome,
|
||||
id, continuity.AttentionDelivering, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.SourceExternalRef, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *ContinuityRepository) MarkAttentionFailed(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||
var item continuity.AttentionItem
|
||||
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, revision=revision+1, action_intent_id=$2, authorized_action_id=$3,
|
||||
action_attempt_id=$4, delivery_outcome=$5
|
||||
WHERE attention_item_id=$6 AND state=$7 AND revision=$8
|
||||
RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,source_external_ref,level,state,summary,due_at_unix_millis,
|
||||
created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,
|
||||
response_id,response_utterance_id,response_operation,response_outcome,responded_at_unix_millis,revision`,
|
||||
continuity.AttentionFailed, intentID, authorizedID, attemptID, outcome,
|
||||
id, continuity.AttentionDelivering, revision).Scan(
|
||||
&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.SourceExternalRef, &item.Level, &item.State,
|
||||
&item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
|
||||
&item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID,
|
||||
&item.ResponseOperation, &item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.AttentionItem{}, ErrAttentionRevisionConflict
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// SatisfyDelivered owns both the Prospective CAS and its idempotency receipt in
|
||||
// one Continuity-local transaction. It never reaches Commit storage.
|
||||
func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) {
|
||||
@@ -95,10 +588,10 @@ func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command con
|
||||
err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
|
||||
SET state=$1, revision=revision+1
|
||||
WHERE prospective_item_id=$2 AND owner_principal_id=$3 AND state=$4 AND revision=$5
|
||||
RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,trigger_at_unix_millis,state,revision`,
|
||||
RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
|
||||
continuity.ProspectiveSatisfied, command.Subject.ProspectiveItemID, command.OwnerPrincipalID,
|
||||
continuity.ProspectiveDelivered, command.Subject.ExpectedRevision).Scan(
|
||||
&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID,
|
||||
&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary,
|
||||
&item.TriggerAtUnixMillis, &item.State, &item.Revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return continuity.ActionReceipt{}, diagnoseProspectiveConflict(ctx, tx, command)
|
||||
@@ -135,6 +628,51 @@ type continuityRowQuerier interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
type continuityExecutor interface {
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type continuityRows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}
|
||||
|
||||
func markAttentionResponded(ctx context.Context, executor continuityExecutor, response continuity.HumanResponse, state continuity.AttentionState) error {
|
||||
tag, err := executor.Exec(ctx, `UPDATE continuity.attention_items
|
||||
SET state=$1, response_id=$2, response_utterance_id=$3, response_operation=$4, response_outcome=$5,
|
||||
responded_at_unix_millis=$6, revision=revision+1
|
||||
WHERE attention_item_id=$7 AND owner_principal_id=$8 AND state IN ($9,$10,$11)`,
|
||||
state, response.ID, response.SourceUtteranceID, response.Operation, response.Outcome, response.RecordedAtUnixMillis,
|
||||
response.TargetAttentionID, response.OwnerPrincipalID, continuity.AttentionDelivered, continuity.AttentionPending, continuity.AttentionFailed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark attention responded: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrAttentionStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) {
|
||||
items := make([]continuity.AttentionItem, 0)
|
||||
for rows.Next() {
|
||||
var item continuity.AttentionItem
|
||||
if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.SourceExternalRef,
|
||||
&item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis,
|
||||
&item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID,
|
||||
&item.DeliveryOutcome, &item.ResponseID, &item.ResponseUtteranceID, &item.ResponseOperation,
|
||||
&item.ResponseOutcome, &item.RespondedAtUnixMillis, &item.Revision); err != nil {
|
||||
return nil, fmt.Errorf("scan attention item: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan attention items: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func loadActionReceipt(ctx context.Context, querier continuityRowQuerier, intentID continuity.CommittedActionIntentID) (continuity.ActionReceipt, error) {
|
||||
var receipt continuity.ActionReceipt
|
||||
err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type EpistemicsRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRepository, error) {
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EpistemicsRepository{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *EpistemicsRepository) Ping(ctx context.Context) error {
|
||||
return r.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value epistemics.SystemExpectation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.system_expectations (
|
||||
expectation_id, original_intent_id, source_utterance_id, expected_outcome, expected_effect,
|
||||
confidence_millis, evidence_basis, recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.OriginalIntentID, value.SourceUtteranceID, value.ExpectedOutcome, value.ExpectedEffect,
|
||||
value.ConfidenceMillis, value.EvidenceBasis, value.RecordedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limit int) ([]epistemics.ExperienceRecord, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid experience limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
experience_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref,
|
||||
capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id,
|
||||
expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id,
|
||||
verification_evidence,confidence_millis,recorded_at_unix_millis
|
||||
FROM epistemics.experience_records ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []epistemics.ExperienceRecord
|
||||
for rows.Next() {
|
||||
var value epistemics.ExperienceRecord
|
||||
if err := rows.Scan(&value.ID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef,
|
||||
&value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID,
|
||||
&value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID,
|
||||
&value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, limit int) ([]epistemics.ReflectionCandidate, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid reflection limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,
|
||||
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||
FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []epistemics.ReflectionCandidate
|
||||
for rows.Next() {
|
||||
var value epistemics.ReflectionCandidate
|
||||
if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID,
|
||||
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, workerID string, nowUnixMillis, leaseMillis int64) (epistemics.ReflectionCandidate, error) {
|
||||
if workerID == "" || leaseMillis < 1 {
|
||||
return epistemics.ReflectionCandidate{}, fmt.Errorf("invalid reflection claim")
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var value epistemics.ReflectionCandidate
|
||||
err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||
FROM epistemics.reflection_candidates
|
||||
WHERE state='OPEN' AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
|
||||
ORDER BY recorded_at_unix_millis,reflection_candidate_id
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
value.State = epistemics.ReflectionCandidateEvaluating
|
||||
value.ClaimedBy = workerID
|
||||
value.ClaimExpiresAtUnixMillis = nowUnixMillis + leaseMillis
|
||||
_, err = tx.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state=$1,claimed_by=$2,claim_expires_at_unix_millis=$3 WHERE reflection_candidate_id=$4 AND state='OPEN'`, value.State, value.ClaimedBy, value.ClaimExpiresAtUnixMillis, value.ID)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) RecoverReflectionClaims(ctx context.Context, nowUnixMillis int64) (int64, error) {
|
||||
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='OPEN',claimed_by='',claim_expires_at_unix_millis=0 WHERE state='EVALUATING' AND claim_expires_at_unix_millis > 0 AND claim_expires_at_unix_millis < $1`, nowUnixMillis)
|
||||
return result.RowsAffected(), err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) ResolveReflectionCandidate(ctx context.Context, id epistemics.ReflectionCandidateID, workerID string, nowUnixMillis int64) error {
|
||||
if id == "" || workerID == "" {
|
||||
return fmt.Errorf("invalid reflection resolution")
|
||||
}
|
||||
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='RESOLVED',claimed_by='',claim_expires_at_unix_millis=0,revision=revision+1 WHERE reflection_candidate_id=$1 AND state='EVALUATING' AND claimed_by=$2 AND claim_expires_at_unix_millis >= $3`, id, workerID, nowUnixMillis)
|
||||
if err == nil && result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("reflection candidate claim lost")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return fmt.Errorf("invalid reflection poll interval")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
|
||||
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
||||
capability_implementation_ref, capability_usage_observation_ref, capability_gap_ref, skill_ref, expectation_id,
|
||||
expected_outcome, actual_outcome, outcome_ref, outcome_detail, human_response_ref, correction_utterance_id,
|
||||
verification_evidence, confidence_millis, recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef,
|
||||
value.CapabilityImplementationRef, value.CapabilityUsageObservationRef, value.CapabilityGapRef, value.SkillRef, value.ExpectationID,
|
||||
value.ExpectedOutcome, value.ActualOutcome, value.OutcomeRef, value.OutcomeDetail, value.HumanResponseRef, value.CorrectionUtteranceID,
|
||||
value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, value epistemics.ReflectionCandidate) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.reflection_candidates (
|
||||
reflection_candidate_id, trigger, state, source_experience_id, original_intent_id, source_utterance_id,
|
||||
summary, evidence_refs, requires_cognition, recorded_at_unix_millis, revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.Trigger, value.State, value.SourceExperienceID, value.OriginalIntentID, value.SourceUtteranceID,
|
||||
value.Summary, value.EvidenceRefs, value.RequiresCognition, value.RecordedAtUnixMillis, value.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error {
|
||||
if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 {
|
||||
return fmt.Errorf("invalid truth bundle")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles (
|
||||
bundle_id,source_id,source_kind,source_locator,source_title,source_publisher,snapshot_id,snapshot_source_id,snapshot_content_digest,snapshot_captured_at_unix_millis,snapshot_freshness,
|
||||
excerpt_id,excerpt_snapshot_id,excerpt_text,excerpt_start,excerpt_end,observation_id,observation_observed_at_unix_millis,claim_id,claim_statement,claim_observation_id,claim_citation_id,claim_verification,
|
||||
belief_id,belief_claim_id,belief_state,citation_id,citation_excerpt_id,citation_evidence,citation_coverage,citation_conflict,citation_resolved_at_unix_millis,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33)
|
||||
ON CONFLICT (bundle_id) DO UPDATE SET recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`,
|
||||
"bundle:"+string(value.Citation.ID), value.Source.ID, value.Source.Kind, value.Source.Locator, value.Source.Title, value.Source.Publisher,
|
||||
value.Snapshot.ID, value.Snapshot.SourceID, value.Snapshot.ContentDigest, value.Snapshot.CapturedAtUnixMillis, value.Snapshot.Freshness,
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type IdentityRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenIdentity(ctx context.Context, config ContinuityConfig) (*IdentityRepository, error) {
|
||||
pool, err := pgxpool.New(ctx, fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", config.Host, config.Port, config.Database, config.User, config.Password))
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
type InteractionRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenInteraction(ctx context.Context, c ContinuityConfig) (*InteractionRepository, error) {
|
||||
p, e := pgxpool.New(ctx, fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", c.Host, c.Port, c.Database, c.User, c.Password))
|
||||
p, e := openPool(ctx, c)
|
||||
return &InteractionRepository{p}, e
|
||||
}
|
||||
func (r *InteractionRepository) Close() { r.pool.Close() }
|
||||
@@ -44,15 +43,25 @@ func (r *InteractionRepository) AppendUtterance(ctx context.Context, c interacti
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *InteractionRepository) SaveSemanticResult(ctx context.Context, interpretation interaction.UtteranceInterpretation, projects []interaction.ProjectReference, relations []interaction.InteractionRelation) error {
|
||||
func (r *InteractionRepository) SaveSemanticResult(ctx context.Context, interpretation interaction.UtteranceInterpretation, candidates []interaction.SemanticCandidate, projects []interaction.ProjectReference, relations []interaction.InteractionRelation) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.utterance_interpretations(interpretation_id,utterance_id,speech_act,confidence_millis,source,assertion_verification,requested_outcome,clarification,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, interpretation.ID, interpretation.UtteranceID, interpretation.SpeechAct, interpretation.ConfidenceMillis, interpretation.Source, interpretation.AssertionVerification, interpretation.RequestedOutcome, interpretation.Clarification, interpretation.RecordedAtUnixMillis); err != nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.utterance_interpretations(interpretation_id,utterance_id,speech_act,secondary_speech_act,confidence_millis,source,assertion_verification,requested_outcome,clarification,needs_cognition,applicability,selection_reason,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, interpretation.ID, interpretation.UtteranceID, interpretation.SpeechAct, interpretation.SecondarySpeechAct, interpretation.ConfidenceMillis, interpretation.Source, interpretation.AssertionVerification, interpretation.RequestedOutcome, interpretation.Clarification, interpretation.NeedsCognition, interpretation.Applicability, interpretation.SelectionReason, interpretation.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.semantic_candidates(candidate_id,utterance_id,source,selection_state,primary_speech_act,secondary_speech_act,confidence_millis,applicability,requested_outcome,clarification,needs_cognition,selection_reason,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(candidate_id) DO UPDATE SET selection_state=EXCLUDED.selection_state,primary_speech_act=EXCLUDED.primary_speech_act,secondary_speech_act=EXCLUDED.secondary_speech_act,confidence_millis=EXCLUDED.confidence_millis,applicability=EXCLUDED.applicability,requested_outcome=EXCLUDED.requested_outcome,clarification=EXCLUDED.clarification,needs_cognition=EXCLUDED.needs_cognition,selection_reason=EXCLUDED.selection_reason,recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`, candidate.ID, candidate.UtteranceID, candidate.Source, candidate.SelectionState, candidate.PrimarySpeechAct, candidate.SecondarySpeechAct, candidate.ConfidenceMillis, candidate.Applicability, candidate.RequestedOutcome, candidate.Clarification, candidate.NeedsCognition, candidate.SelectionReason, candidate.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, hint := range candidate.CapabilityHints {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.semantic_candidate_capability_hints(candidate_id,capability_kind) VALUES($1,$2) ON CONFLICT DO NOTHING`, candidate.ID, hint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, project := range projects {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.project_references(project_reference_id,utterance_id,project_id,project_display_name,role,provenance,confidence_millis,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, project.ID, project.UtteranceID, project.ProjectID, project.ProjectDisplayName, project.Role, project.Provenance, project.ConfidenceMillis, project.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,6 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
@@ -13,8 +12,7 @@ import (
|
||||
type SituationRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenSituation(ctx context.Context, config ContinuityConfig) (*SituationRepository, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", config.Host, config.Port, config.Database, config.User, config.Password)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,23 +2,32 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type WorkRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
var (
|
||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||
)
|
||||
|
||||
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", config.Host, config.Port, config.Database, config.User, config.Password)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WorkRepository{pool}, nil
|
||||
}
|
||||
func (r *WorkRepository) Close() { r.pool.Close() }
|
||||
func (r *WorkRepository) Close() { r.pool.Close() }
|
||||
func (r *WorkRepository) Ping(ctx context.Context) error { return r.pool.Ping(ctx) }
|
||||
func (r *WorkRepository) Create(ctx context.Context, target work.TaskTarget) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.tasks (task_id,goal_id,scope_id,revision) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id) DO NOTHING`, target.TaskID, target.GoalID, target.ScopeID, target.Revision)
|
||||
return err
|
||||
@@ -39,3 +48,449 @@ func (r *WorkRepository) Load(ctx context.Context, id work.TaskID) (work.TaskTar
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dispatch intent: %w", err)
|
||||
}
|
||||
var existing work.AgentDispatchIntent
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dispatch intent: %w", err)
|
||||
}
|
||||
if existing != intent {
|
||||
return ErrDispatchIntentConflict
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
|
||||
var intent work.AgentDispatchIntent
|
||||
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) EnqueueDispatch(ctx context.Context, item work.AgentDispatchQueueItem) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.agent_dispatch_queue(dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(dispatch_id) DO NOTHING`, item.DispatchID, item.State, item.AvailableAtUnixMillis, item.Attempts, item.ClaimedBy, item.UpdatedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string, nowUnixMillis int64) (work.AgentDispatchQueueItem, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return work.AgentDispatchQueueItem{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var item work.AgentDispatchQueueItem
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return work.AgentDispatchQueueItem{}, nil
|
||||
}
|
||||
return work.AgentDispatchQueueItem{}, err
|
||||
}
|
||||
item.State = work.AgentDispatchClaimed
|
||||
item.Attempts++
|
||||
item.ClaimedBy = workerID
|
||||
item.UpdatedAtUnixMillis = nowUnixMillis
|
||||
_, err = tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=$1,attempts=$2,claimed_by=$3,updated_at_unix_millis=$4 WHERE dispatch_id=$5 AND state='QUEUED'`, item.State, item.Attempts, item.ClaimedBy, item.UpdatedAtUnixMillis, item.DispatchID)
|
||||
if err != nil {
|
||||
return work.AgentDispatchQueueItem{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return work.AgentDispatchQueueItem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) WaitForDispatch(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return fmt.Errorf("invalid worker poll interval")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, workerID string, state work.AgentDispatchQueueState, nowUnixMillis int64) error {
|
||||
if state != work.AgentDispatchCompleted && state != work.AgentDispatchFailed {
|
||||
return fmt.Errorf("invalid terminal dispatch state: %s", state)
|
||||
}
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=$1,updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, state, nowUnixMillis, dispatchID, workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrAgentCallbackRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts) VALUES($1,$2,$3,$4,$5) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range graph.Nodes {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, edge := range graph.Edges {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_edges(graph_id,revision,from_node_id,to_node_id) VALUES($1,$2,$3,$4) ON CONFLICT(graph_id,revision,from_node_id,to_node_id) DO NOTHING`, graph.ID, graph.Revision, edge.From, edge.To); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) {
|
||||
var graph work.WorkGraph
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var node work.GraphNode
|
||||
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
graph.Nodes = append(graph.Nodes, node)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
rows.Close()
|
||||
edges, err := r.pool.Query(ctx, `SELECT from_node_id,to_node_id FROM work.graph_edges WHERE graph_id=$1 AND revision=$2 ORDER BY from_node_id,to_node_id`, graphID, revision)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
for edges.Next() {
|
||||
var edge work.GraphEdge
|
||||
if err := edges.Scan(&edge.From, &edge.To); err != nil {
|
||||
edges.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
graph.Edges = append(graph.Edges, edge)
|
||||
}
|
||||
if err := edges.Err(); err != nil {
|
||||
edges.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
edges.Close()
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state='QUEUED',claimed_by='',updated_at_unix_millis=$1 WHERE state='CLAIMED' AND updated_at_unix_millis <= $2`, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return command.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveAgentLease(ctx context.Context, lease work.AgentLease) error {
|
||||
err := r.pool.QueryRow(ctx, `INSERT INTO work.agent_leases(lease_id,run_id,worker_id,fencing_token,state,acquired_at_unix_millis,expires_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(run_id) DO UPDATE SET lease_id=EXCLUDED.lease_id,worker_id=EXCLUDED.worker_id,fencing_token=EXCLUDED.fencing_token,state=EXCLUDED.state,acquired_at_unix_millis=EXCLUDED.acquired_at_unix_millis,expires_at_unix_millis=EXCLUDED.expires_at_unix_millis WHERE work.agent_leases.state='EXPIRED' OR work.agent_leases.expires_at_unix_millis <= EXCLUDED.acquired_at_unix_millis RETURNING run_id`, lease.ID, lease.RunID, lease.WorkerID, lease.FencingToken, lease.State, lease.AcquiredAt.UnixMilli(), lease.ExpiresAt.UnixMilli()).Scan(new(string))
|
||||
if err != nil {
|
||||
return ErrAgentLeaseUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RenewAgentLease(ctx context.Context, lease work.AgentLease, expiresAt time.Time) error {
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_leases SET expires_at_unix_millis=$1 WHERE run_id=$2 AND worker_id=$3 AND fencing_token=$4 AND state='ACTIVE' AND expires_at_unix_millis > $5`, expiresAt.UnixMilli(), lease.RunID, lease.WorkerID, lease.FencingToken, time.Now().UnixMilli())
|
||||
if err != nil || command.RowsAffected() != 1 {
|
||||
return ErrAgentLeaseUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) AcceptAgentCallback(ctx context.Context, callback work.AgentCallback) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var runID string
|
||||
err = tx.QueryRow(ctx, `SELECT run_id FROM work.agent_leases WHERE run_id=$1 AND worker_id=$2 AND fencing_token=$3 AND state='ACTIVE' AND expires_at_unix_millis > $4 FOR UPDATE`, callback.RunID, callback.WorkerID, callback.FencingToken, callback.ObservedAt.UnixMilli()).Scan(&runID)
|
||||
if err != nil {
|
||||
return ErrAgentCallbackRejected
|
||||
}
|
||||
command, err := tx.Exec(ctx, `INSERT INTO work.agent_callback_heads(run_id,last_sequence,updated_at_unix_millis) VALUES($1,$2,$3) ON CONFLICT(run_id) DO UPDATE SET last_sequence=EXCLUDED.last_sequence,updated_at_unix_millis=EXCLUDED.updated_at_unix_millis WHERE work.agent_callback_heads.last_sequence < EXCLUDED.last_sequence`, runID, callback.Sequence, callback.ObservedAt.UnixMilli())
|
||||
if err != nil || command.RowsAffected() != 1 {
|
||||
return ErrAgentCallbackRejected
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SavePlanningResult(ctx context.Context, result work.PlanningResult) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
goal := result.Goal
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.goals(goal_id,source_intent_id,source_utterance_id,actor_id,principal_id,scope_id,project_id,project_display_name,desired_outcome,constraints_text,state,provenance,revision,created_at_unix_millis,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(goal_id) DO NOTHING`, goal.ID, goal.SourceIntentID, goal.SourceUtteranceID, goal.ActorID, goal.PrincipalID, goal.ScopeID, goal.ProjectID, goal.ProjectDisplayName, goal.DesiredOutcome, goal.Constraints, goal.State, goal.Provenance, goal.Revision, goal.CreatedAtUnixMillis, goal.UpdatedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := result.Candidate
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.plan_candidates(plan_candidate_id,goal_id,source,state,summary,validation_summary,requires_clarification,accepted_at_unix_millis,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(plan_candidate_id) DO NOTHING`, candidate.ID, candidate.GoalID, candidate.Source, candidate.State, candidate.Summary, candidate.ValidationSummary, candidate.RequiresClarification, candidate.AcceptedAtUnixMillis, candidate.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
plan := result.Plan
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.work_plans(work_plan_id,goal_id,accepted_candidate_id,revision,state,summary,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(work_plan_id) DO NOTHING`, plan.ID, plan.GoalID, plan.AcceptedCandidateID, plan.Revision, plan.State, plan.Summary, plan.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, requirement := range candidate.Requirements {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.capability_requirements(capability_requirement_id,plan_candidate_id,capability_kind,capability_id,purpose,state,epistemic_precondition,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(capability_requirement_id) DO NOTHING`, requirement.ID, requirement.PlanCandidateID, requirement.CapabilityKind, requirement.CapabilityID, requirement.Purpose, requirement.State, requirement.EpistemicPrecondition, requirement.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, step := range candidate.Steps {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.plan_steps(plan_step_id,plan_candidate_id,ordinal,kind,description,requires_capability_id,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(plan_step_id) DO NOTHING`, step.ID, step.PlanCandidateID, step.Ordinal, step.Kind, step.Description, step.RequiresCapabilityID, step.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, delegation := range candidate.Delegations {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.delegation_candidates(delegation_candidate_id,plan_candidate_id,capability_requirement_id,kind,executor_hint,state,reason,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(delegation_candidate_id) DO NOTHING`, delegation.ID, delegation.PlanCandidateID, delegation.CapabilityRequirementID, delegation.Kind, delegation.ExecutorHint, delegation.State, delegation.Reason, delegation.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDelegationBundle(ctx context.Context, delegation work.Delegation, run work.AgentRun) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.delegations(
|
||||
delegation_id,goal_id,work_plan_id,plan_candidate_id,capability_requirement_id,capability_kind,
|
||||
selected_implementation_id,selected_implementation_revision,selected_implementation_kind,protocol,
|
||||
source_intent_id,source_utterance_id,conversation_id,project_id,project_display_name,desired_outcome,
|
||||
constraint_summary,verification_summary,context_summary,context_refs,state,latest_run_id,latest_result_id,
|
||||
created_at_unix_millis,updated_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
|
||||
ON CONFLICT(delegation_id) DO NOTHING`,
|
||||
delegation.ID, delegation.GoalID, delegation.PlanID, delegation.PlanCandidateID, delegation.CapabilityRequirementID,
|
||||
delegation.CapabilityKind, delegation.SelectedImplementationID, delegation.SelectedImplementationRev,
|
||||
delegation.SelectedImplementationKind, delegation.Protocol, delegation.SourceIntentID, delegation.SourceUtteranceID,
|
||||
delegation.ConversationID, delegation.ProjectID, delegation.ProjectDisplayName, delegation.DesiredOutcome,
|
||||
delegation.ConstraintSummary, delegation.VerificationSummary, delegation.ContextSummary, delegation.ContextRefs,
|
||||
delegation.State, delegation.LatestRunID, delegation.LatestResultID, delegation.CreatedAtUnixMillis, delegation.UpdatedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.agent_runs(
|
||||
run_id,delegation_id,implementation_id,implementation_revision,protocol,state,derived_instruction_summary,
|
||||
started_at_unix_millis,updated_at_unix_millis,completed_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(run_id) DO NOTHING`,
|
||||
run.ID, run.DelegationID, run.ImplementationID, run.ImplementationRevision, run.Protocol, run.State,
|
||||
run.DerivedInstructionSummary, run.StartedAtUnixMillis, run.UpdatedAtUnixMillis, run.CompletedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveAgentCheckpoint(ctx context.Context, run work.AgentRun, checkpoint work.AgentCheckpoint) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.agent_runs
|
||||
SET state=$2,updated_at_unix_millis=$3,completed_at_unix_millis=$4
|
||||
WHERE run_id=$1`, run.ID, run.State, run.UpdatedAtUnixMillis, run.CompletedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.delegations
|
||||
SET state=$2,updated_at_unix_millis=$3,latest_run_id=$4
|
||||
WHERE delegation_id=$1`, checkpoint.DelegationID, delegationStateForRun(run.State), checkpoint.RecordedAtUnixMillis, run.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.agent_checkpoints(
|
||||
checkpoint_id,delegation_id,run_id,state,blocker_class,completed_summary,remaining_summary,test_summary,
|
||||
branch_name,commit_hash,next_step,artifact_refs,recorded_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(checkpoint_id) DO NOTHING`,
|
||||
checkpoint.ID, checkpoint.DelegationID, checkpoint.RunID, checkpoint.State, checkpoint.BlockerClass,
|
||||
checkpoint.CompletedSummary, checkpoint.RemainingSummary, checkpoint.TestSummary, checkpoint.BranchName,
|
||||
checkpoint.CommitHash, checkpoint.NextStep, checkpoint.ArtifactRefs, checkpoint.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveAgentResult(ctx context.Context, run work.AgentRun, result work.AgentResult) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.agent_runs
|
||||
SET state=$2,updated_at_unix_millis=$3,completed_at_unix_millis=$4
|
||||
WHERE run_id=$1`, run.ID, run.State, run.UpdatedAtUnixMillis, run.CompletedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.delegations
|
||||
SET state=$2,updated_at_unix_millis=$3,latest_run_id=$4,latest_result_id=$5
|
||||
WHERE delegation_id=$1`, result.DelegationID, work.DelegationVerifying, result.RecordedAtUnixMillis, run.ID, result.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.agent_results(
|
||||
result_id,delegation_id,run_id,state,summary,outcome_ref,verification_required,verification_summary,artifact_refs,recorded_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(result_id) DO NOTHING`,
|
||||
result.ID, result.DelegationID, result.RunID, result.State, result.Summary, result.OutcomeRef, result.VerificationRequired,
|
||||
result.VerificationSummary, result.ArtifactRefs, result.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveResumedRun(ctx context.Context, delegation work.Delegation, run work.AgentRun) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.delegations
|
||||
SET state=$2,updated_at_unix_millis=$3,latest_run_id=$4
|
||||
WHERE delegation_id=$1`, delegation.ID, delegation.State, delegation.UpdatedAtUnixMillis, delegation.LatestRunID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.agent_runs(
|
||||
run_id,delegation_id,implementation_id,implementation_revision,protocol,state,derived_instruction_summary,
|
||||
started_at_unix_millis,updated_at_unix_millis,completed_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(run_id) DO NOTHING`,
|
||||
run.ID, run.DelegationID, run.ImplementationID, run.ImplementationRevision, run.Protocol, run.State,
|
||||
run.DerivedInstructionSummary, run.StartedAtUnixMillis, run.UpdatedAtUnixMillis, run.CompletedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDelegationVerification(ctx context.Context, delegation work.Delegation, verification work.DelegationVerification) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err = tx.Exec(ctx, `UPDATE work.delegations
|
||||
SET state=$2,updated_at_unix_millis=$3
|
||||
WHERE delegation_id=$1`, delegation.ID, delegation.State, delegation.UpdatedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO work.delegation_verifications(
|
||||
verification_id,delegation_id,run_id,result_id,verified,summary,evidence_refs,recorded_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(verification_id) DO NOTHING`,
|
||||
verification.ID, verification.DelegationID, verification.RunID, verification.ResultID, verification.Verified,
|
||||
verification.Summary, verification.EvidenceRefs, verification.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LatestAgentResult(ctx context.Context, delegationID work.DelegationID) (work.AgentResult, error) {
|
||||
var value work.AgentResult
|
||||
err := r.pool.QueryRow(ctx, `SELECT result_id,delegation_id,run_id,state,summary,outcome_ref,verification_required,verification_summary,artifact_refs,recorded_at_unix_millis
|
||||
FROM work.agent_results WHERE delegation_id=$1 ORDER BY recorded_at_unix_millis DESC, result_id DESC LIMIT 1`, delegationID).
|
||||
Scan(&value.ID, &value.DelegationID, &value.RunID, &value.State, &value.Summary, &value.OutcomeRef, &value.VerificationRequired, &value.VerificationSummary, &value.ArtifactRefs, &value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentResult{}, fmt.Errorf("load latest agent result: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) CountAgentRuns(ctx context.Context, delegationID work.DelegationID) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM work.agent_runs WHERE delegation_id=$1`, delegationID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count agent runs: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LatestAgentCheckpoint(ctx context.Context, delegationID work.DelegationID) (work.AgentCheckpoint, error) {
|
||||
var value work.AgentCheckpoint
|
||||
err := r.pool.QueryRow(ctx, `SELECT checkpoint_id,delegation_id,run_id,state,blocker_class,completed_summary,remaining_summary,test_summary,branch_name,commit_hash,next_step,artifact_refs,recorded_at_unix_millis
|
||||
FROM work.agent_checkpoints WHERE delegation_id=$1 ORDER BY recorded_at_unix_millis DESC, checkpoint_id DESC LIMIT 1`, delegationID).
|
||||
Scan(&value.ID, &value.DelegationID, &value.RunID, &value.State, &value.BlockerClass, &value.CompletedSummary, &value.RemainingSummary,
|
||||
&value.TestSummary, &value.BranchName, &value.CommitHash, &value.NextStep, &value.ArtifactRefs, &value.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentCheckpoint{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadDelegation(ctx context.Context, id work.DelegationID) (work.Delegation, error) {
|
||||
var value work.Delegation
|
||||
err := r.pool.QueryRow(ctx, `SELECT delegation_id,goal_id,work_plan_id,plan_candidate_id,capability_requirement_id,capability_kind,
|
||||
selected_implementation_id,selected_implementation_revision,selected_implementation_kind,protocol,
|
||||
source_intent_id,source_utterance_id,conversation_id,project_id,project_display_name,desired_outcome,constraint_summary,
|
||||
verification_summary,context_summary,context_refs,state,latest_run_id,latest_result_id,created_at_unix_millis,updated_at_unix_millis
|
||||
FROM work.delegations WHERE delegation_id=$1`, id).
|
||||
Scan(&value.ID, &value.GoalID, &value.PlanID, &value.PlanCandidateID, &value.CapabilityRequirementID, &value.CapabilityKind,
|
||||
&value.SelectedImplementationID, &value.SelectedImplementationRev, &value.SelectedImplementationKind, &value.Protocol,
|
||||
&value.SourceIntentID, &value.SourceUtteranceID, &value.ConversationID, &value.ProjectID, &value.ProjectDisplayName,
|
||||
&value.DesiredOutcome, &value.ConstraintSummary, &value.VerificationSummary, &value.ContextSummary, &value.ContextRefs,
|
||||
&value.State, &value.LatestRunID, &value.LatestResultID, &value.CreatedAtUnixMillis, &value.UpdatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.Delegation{}, fmt.Errorf("load delegation: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadAgentRun(ctx context.Context, id work.AgentRunID) (work.AgentRun, error) {
|
||||
var value work.AgentRun
|
||||
err := r.pool.QueryRow(ctx, `SELECT run_id,delegation_id,implementation_id,implementation_revision,protocol,state,derived_instruction_summary,started_at_unix_millis,updated_at_unix_millis,completed_at_unix_millis
|
||||
FROM work.agent_runs WHERE run_id=$1`, id).
|
||||
Scan(&value.ID, &value.DelegationID, &value.ImplementationID, &value.ImplementationRevision, &value.Protocol, &value.State, &value.DerivedInstructionSummary,
|
||||
&value.StartedAtUnixMillis, &value.UpdatedAtUnixMillis, &value.CompletedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentRun{}, fmt.Errorf("load agent run: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func delegationStateForRun(state work.AgentRunState) work.DelegationState {
|
||||
switch state {
|
||||
case work.AgentRunStarted, work.AgentRunRunning:
|
||||
return work.DelegationRunning
|
||||
case work.AgentRunWaiting, work.AgentRunCheckpointed, work.AgentRunExecutionWindowExhausted:
|
||||
return work.DelegationWaiting
|
||||
case work.AgentRunBlocked:
|
||||
return work.DelegationBlocked
|
||||
case work.AgentRunCompleted:
|
||||
return work.DelegationVerifying
|
||||
case work.AgentRunCancelled:
|
||||
return work.DelegationCancelled
|
||||
case work.AgentRunFailed:
|
||||
return work.DelegationFailed
|
||||
default:
|
||||
return work.DelegationPending
|
||||
}
|
||||
}
|
||||
|
||||
func latestRowErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrHTTPSourceRejected = errors.New("http research source rejected")
|
||||
|
||||
type HTTPConnector struct {
|
||||
Client *http.Client
|
||||
AllowedHosts map[string]struct{}
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
func (c HTTPConnector) Collect(ctx context.Context, request epistemics.WebResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
|
||||
if request.Plan.Validate() != nil || request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
u, err := url.Parse(request.URL)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || !c.allowedHost(strings.ToLower(u.Hostname())) {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
client := c.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
maxBytes := c.MaxBytes
|
||||
if maxBytes <= 0 || maxBytes > 4<<20 {
|
||||
maxBytes = 256 << 10
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("read web source: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("web source status: %s", response.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || int64(len(body)) > maxBytes || len(body) == 0 {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
key := sha256.Sum256([]byte(u.String() + "\x00" + digestText))
|
||||
keyText := hex.EncodeToString(key[:])
|
||||
snapshotID := epistemics.SourceSnapshotID("snapshot:" + keyText)
|
||||
return epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey, Statement: request.Statement,
|
||||
Source: epistemics.Source{ID: request.SourceID, Kind: "web", Locator: u.String(), Title: request.Title, Publisher: u.Hostname()},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: snapshotID, Text: string(body), Start: 0, End: int64(len(body))},
|
||||
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c HTTPConnector) allowedHost(host string) bool {
|
||||
_, ok := c.AllowedHosts[host]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrRepositoryPathRejected = errors.New("repository source path rejected")
|
||||
|
||||
type RepositoryConnector struct{ Root string }
|
||||
|
||||
func (c RepositoryConnector) Collect(ctx context.Context, request epistemics.RepositoryResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
|
||||
if err := request.Plan.Validate(); err != nil {
|
||||
return epistemics.ResearchCandidate{}, err
|
||||
}
|
||||
if request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
root, err := filepath.Abs(c.Root)
|
||||
if err != nil || root == "" {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(request.Path)))
|
||||
if err != nil || !withinRoot(root, path) {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return epistemics.ResearchCandidate{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || len(content) == 0 {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("read repository source: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
key := sha256.Sum256([]byte(filepath.ToSlash(path) + "\x00" + digestText))
|
||||
keyText := hex.EncodeToString(key[:])
|
||||
return epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey,
|
||||
Statement: request.Statement,
|
||||
Source: epistemics.Source{ID: request.SourceID, Kind: "repository", Locator: "file://" + filepath.ToSlash(path), Title: filepath.Base(path), Publisher: "local-repository"},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID("snapshot:" + keyText), SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: epistemics.SourceSnapshotID("snapshot:" + keyText), Text: string(content), Start: 0, End: int64(len(content))},
|
||||
Evidence: epistemics.EvidenceObserved,
|
||||
Coverage: epistemics.CoverageComplete,
|
||||
Conflict: epistemics.ConflictNone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func withinRoot(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR-0012 - Intent To Work Planning Foundation
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-17
|
||||
Accepted under: DEV-000063 milestone decision
|
||||
|
||||
## Decision
|
||||
|
||||
`work` remains the canonical owner for Goal, Task, Decision and the new V1 planning foundation. No new canonical owner is introduced.
|
||||
|
||||
Help is the central user-facing concept. Goal, Work and Plan are optional durable coordination primitives, not mandatory records for every conversation. A simple explanation, ordinary answer, short cognitive response, direct reminder capability, safe deterministic operation, or capability-gap response may bypass durable Work entirely.
|
||||
|
||||
Human chat must not reduce Domovoy to `chat -> prompt -> model` or to a Codex-specific task generator. The canonical pipeline is:
|
||||
|
||||
When durable coordination is actually needed, the canonical path is:
|
||||
|
||||
`HumanUtterance -> OriginalHumanIntent -> Goal -> PlanCandidate -> accepted Work/Plan state -> CapabilityRequirement -> DelegationCandidate/Action/Investigation/Observation/Outcome`.
|
||||
|
||||
Prompts, Codex instructions, mail payloads, reminders and provider requests are executor-adapter representations. They are not canonical human intent, Goal or Plan.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `interaction` continues to own append-only human utterances, original intent provenance and typed utterance interpretations.
|
||||
- `work` owns durable Goals, lifecycle, planning state, plan candidates, accepted plan summaries, capability requirements and general delegation candidates.
|
||||
- `capability` owns available capability/binding registry and health. Work may reference capability requirements by typed IDs/kinds but does not select a concrete provider as canonical truth.
|
||||
- `epistemics` owns verified/unverified/unknown evidence semantics. Work planning must preserve epistemic state and may add verification requirements before risky work.
|
||||
- `authority` and `commit` remain required for any real mutation or external side effect. Planning creates no permission and no execution claim.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Work PlanCandidates are structured candidates that must be validated before persistence or acceptance. Cognitive planning may later propose candidates only through the Cognitive Gateway/Router; model output never directly mutates canonical Work state. DEV-000063 may implement deterministic V1 candidate planning and keep cognitive candidate generation as a typed extension point.
|
||||
|
||||
CapabilityRequirement is executor-neutral. Examples include `READ_EMAIL`, `SEND_EMAIL`, `READ_DATABASE`, `QUERY_LOGS`, `INSPECT_PROJECT`, `MODIFY_CODE`, `RUN_TESTS`, `WEB_RESEARCH`, `COGNITIVE_REASONING`, and `SCHEDULE_REMINDER`. This is an extensible vocabulary, not a closed ontology and not an executor list.
|
||||
|
||||
DelegationCandidate is general. `Codex` is only a possible future implementation for `MODIFY_CODE`; mail, research, scheduler, device and database adapters can use the same canonical Work model without changing OriginalHumanIntent or Goal.
|
||||
|
||||
## Consequences
|
||||
|
||||
`/chat` can choose a HelpPath first. Only the durable-work path creates a planning-only Goal and Plan. Other paths can answer, use cognition, report a missing capability, or describe a direct capability requirement without materializing a Goal/Plan. It must not pretend work was completed. Completion remains tied to outcome semantics, not to a model response.
|
||||
|
||||
Project is only one possible context reference. The Work foundation stores project context as optional opaque references and must later permit people, emails, documents, images, servers, organizations, research subjects and other context types without redesigning Goal/Plan.
|
||||
|
||||
Capability gaps are first-class planning information. "I cannot do this yet" is not necessarily terminal in future milestones: the system may later discover, connect, configure or build a missing capability and return to the original need. DEV-000063 does not implement self-extension; it preserves the extension path.
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR-0013 — Capability Resolution, Selection and Gap Handling Foundation
|
||||
|
||||
Status: ACCEPTED
|
||||
|
||||
Date: 2026-08-17
|
||||
|
||||
## Context
|
||||
|
||||
DEV-000063 introduced Work-owned `CapabilityRequirement` without selecting or executing concrete implementations. The system now needs a provider-neutral way to answer which real capabilities can satisfy a requirement, whether they are operationally usable, whether policy permits them, and what typed gap remains when no implementation is eligible.
|
||||
|
||||
The Core Semantic Model already assigns Capability registry/bindings ownership to the Capability module. Cognitive Registry is a specialized capability registry for model/provider/model-binding semantics. This ADR must not create a parallel cognitive source of truth.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Keep `capability` as the canonical owner for capability descriptors, implementation registrations, implementation health/availability, resolution results, typed gaps, acquisition strategy candidates and generic capability usage observations.
|
||||
2. Represent a capability separately from an implementation:
|
||||
- `Capability` / `CapabilityRef`: what the system can obtain or do.
|
||||
- `CapabilityImplementation`: a concrete adapter, provider, runtime, agent or internal function that may satisfy that capability.
|
||||
3. Capability identity is typed but extensible. Known `CapabilityKind` constants are allowed for current semantics, but the registry stores string capability kinds and stable capability IDs without imposing a closed universe.
|
||||
4. Resolver V1 returns `AVAILABLE`, `PARTIALLY_AVAILABLE`, `UNAVAILABLE` or `UNKNOWN` with eligible implementations and typed exclusion/gap reasons.
|
||||
5. Selection V1 is deterministic and evidence-weighted. It must rank eligible implementations by typed evidence and stable IDs, not by hardcoded provider/adapter names.
|
||||
6. CredentialRef is allowed as implementation metadata, but credential existence never means capability availability or Authority. Secret values remain outside canonical state and are resolved only by trusted runtimes.
|
||||
7. Cognitive systems remain integrated through the existing Cognitive Registry and Router. General capability resolution may express the conceptual relation that cognition is a capability, but cognitive invocation continues through the existing gateway/router.
|
||||
8. Reminder scheduling is the first concrete production capability for this foundation. It creates Continuity-owned `ProspectiveItem` state through the Continuity runtime boundary. It does not create durable Work for simple reminders.
|
||||
9. Unsupported email sending creates a Capability-owned `CapabilityGap` correlated to the original human intent. It must not claim execution and must not become an automatic Codex/self-extension task.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `capability` gains generic implementation, resolution, gap and usage-observation contracts/tables.
|
||||
- `continuity` gains a narrow authenticated creator reminder-registration endpoint that owns creation of `ProspectiveItem` rows.
|
||||
- `/chat` can schedule a simple reminder through capability resolution and can persist a typed gap for unavailable capabilities.
|
||||
- No cognitive binding is promoted to `ACTIVE`.
|
||||
- No new canonical owner, broad plugin marketplace, autonomous adapter installation or self-modifying loop is introduced.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-0014 — Assistant Presence, Attention, and Human Surface V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000065
|
||||
|
||||
## Context
|
||||
|
||||
Domovoy already records human utterances, original human intent, current focus, capability requirements, capability gaps, and Continuity-owned reminder state. Before this decision, creating a reminder only persisted an `ARMED` `ProspectiveItem`; it did not by itself return to the human when the due condition arrived.
|
||||
|
||||
The system already has the correct outbound mutation foundation: Commit owns authorized external actions, Capability owns notification bindings, and the notification receiver persists idempotent receipts. DEV-000065 must therefore connect due Continuity state to the existing Commit/Executor path instead of creating a parallel reminder or notification subsystem.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Continuity remains the canonical owner of future obligations and prospective state.
|
||||
2. Continuity also owns the V1 typed `AttentionItem` state because attention is the human-facing relevance decision derived from future obligations and other future internal events.
|
||||
3. `AttentionItem` V1 is deliberately narrow: it records source, owner, level, state, summary, due time, and delivery evidence references. It is not a task manager, dashboard model, or capability registry.
|
||||
4. A due reminder follows the existing prospective lifecycle: `ARMED → TRIGGERED → REVALIDATING → DELIVERED`. `DELIVERED` means the due obligation has reached the human-contact phase; it does not grant external mutation authority by itself.
|
||||
5. Human notification remains a Commit-owned external action using the existing `deliver-prospective-notification-v1` operation and Capability-owned active notification binding.
|
||||
6. Commit may authorize a prospective notification using a Continuity prospective item as the live justification. A Work `TaskTarget` is not mandatory for this operation when the operation is internally invoked for a validated due `ProspectiveItem`.
|
||||
7. The due processing runtime may poll canonical PostgreSQL state, but correctness must come from persisted state transitions and idempotency constraints, not from process-local timers.
|
||||
8. The Human Assistant web surface is separate from the Operator Console. Chat remains primary; Today/Attention and History are human-readable projections built through runtime APIs over canonical state.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Continuity writes only Continuity-owned prospective and attention state.
|
||||
- Commit writes only Commit-owned action/attempt state and invokes the existing notification executor.
|
||||
- Capability owns the notification binding and health/availability semantics.
|
||||
- Interaction owns conversations, utterances, interpretations, and original human intent.
|
||||
- No model/provider call is required for deterministic reminder firing.
|
||||
- Model output never directly mutates Continuity, Commit, or Capability state.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop.
|
||||
- No new canonical owner.
|
||||
- No generic scheduler/event-bus/autonomous-agent loop.
|
||||
- No new third-party dependency.
|
||||
- No new notification channel beyond the existing receiver/Windows display path.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reminders survive runtime restart because due detection scans canonical `ProspectiveItem` rows.
|
||||
- Duplicate notification is prevented by Continuity CAS/unique attention source and Commit/receiver idempotency.
|
||||
- The first attention source is reminder due events; future sources can reuse the same attention path without changing ownership.
|
||||
- Human UI no longer needs internal UUIDs, binding IDs, provider IDs, or raw lifecycle enums for ordinary use.
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-0015 — Human Response and Continuation Semantics V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000066
|
||||
|
||||
## Context
|
||||
|
||||
DEV-000065 made Domovoy present beyond an HTTP request: reminders can become due, produce Continuity-owned Attention, and deliver a governed notification. The next step is not a reminder command set; it is a general way to interpret a human response to an existing context.
|
||||
|
||||
Examples include: a reminder after notification, waiting work requiring a decision, a future email thread, or a future capability gap/acquisition context. V1 implements reminders as the first real target while preserving extensible typed target references for future owners.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Continuity owns `HumanResponse` and `ResponseTargetResolution` V1 because they continue durable waits/attention and may mutate Continuity-owned future obligation state.
|
||||
2. Interaction remains the owner of the original `HumanUtterance` and `OriginalHumanIntent`; Continuity stores references to those records, not copies of their authority.
|
||||
3. V1 response operations are typed but extensible: `ACKNOWLEDGE`, `COMPLETE`, `SNOOZE`, `CANCEL`, `DISMISS`, `CONTINUE`, `REJECT`, plus `UNKNOWN` for fail-closed interpretation.
|
||||
4. V1 target references are typed: `ATTENTION_ITEM`, `PROSPECTIVE_ITEM`, `WORK_TASK`, `CAPABILITY_GAP`, `INTERACTION_CONTEXT`, and `UNKNOWN`. DEV-000066 implements only Continuity-owned reminder/attention mutations.
|
||||
5. Obvious deterministic responses may execute without cognition. Ambiguous responses must ask for clarification and record an append-only response record without mutating the target.
|
||||
6. A human response is not automatic verified world truth. For reminder completion, V1 records `USER_REPORTED_COMPLETE`; this satisfies the reminder according to policy but does not create a verified external-world fact.
|
||||
7. UI buttons, if present, must post the same semantic response text/API path as natural chat. They are projections, not a separate command system.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Continuity writes only Continuity-owned response, attention, and prospective state.
|
||||
- Interaction writes conversations/utterances/intents before Continuity response handling.
|
||||
- No external mutation or Commit/Executor operation is introduced in DEV-000066.
|
||||
- No model output directly mutates canonical state.
|
||||
- Future Codex/mail/capability-gap continuations are representable by typed references, but not implemented.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Codex Bridge, Gmail, MCP, voice, mobile, browser automation, self-extension loop, or generalized orchestration.
|
||||
- No universal object ontology.
|
||||
- No reminder-specific parallel subsystem.
|
||||
|
||||
## Consequences
|
||||
|
||||
- “Готово”, “Отложи”, “Отмени” can continue the latest obvious reminder/attention context without internal IDs.
|
||||
- Snooze re-arms the same `ProspectiveItem` with a new due time, preserving audit history.
|
||||
- Cancel suppresses the future obligation so the due scanner will not deliver it.
|
||||
- Today/Attention can show active, snoozed, completed, and cancelled human-readable states.
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR-0016 — Experience, Reflection and Skill Evolution Foundation V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000067
|
||||
|
||||
## Context
|
||||
|
||||
Domovoy can now receive human utterances, interpret intent, optionally plan work, resolve capabilities, perform narrow actions through existing owner boundaries, observe outcomes and accept human responses. The next architecture step is to preserve what happened and why, so repeated successful understanding may become reusable skill while failures, corrections and drift can return the system to broader thinking.
|
||||
|
||||
This must not introduce a giant learning manager, self-modifying loop, prompt optimizer or permanent hand-coded user profile. Learning evidence must remain historical and revisable.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Do not create a new canonical owner for DEV-000067.
|
||||
2. `epistemics` owns provider-neutral experience, expectation, outcome and reflection-candidate semantics because these are evidence/provenance about what happened and why. It stores typed references to Interaction, Continuity, Work and Capability records rather than copying their canonical state.
|
||||
3. `capability` owns skill-profile and fast-path lifecycle semantics because Skill V1 is a reusable capability/implementation pattern derived from evidence, not a new authority or profession category.
|
||||
4. A `SystemExpectation` records what the system expected from meaningful help before outcome is known. "Model returned text" and "HTTP 200" are not sufficient success semantics.
|
||||
5. `ExperienceRecord` links original human intent/utterance, context refs, selected help method, capability/implementation refs, expectation, outcome class, human response/correction refs and verification evidence.
|
||||
6. `ReflectionCandidate` is append-only evidence that broader reflection may be warranted. It may be deterministic or routed through the Cognitive Gateway, but model output cannot directly mutate canonical beliefs, skills, authority, Go source or deployment.
|
||||
7. Skill lifecycle is explicit: `CANDIDATE`, `EVALUATING`, `VALIDATED`, `DEGRADED`, `SUSPENDED`, `RETIRED`. Replacing or suspending a skill never deletes source observations, experiences, corrections, outcomes, beliefs or original human intent history.
|
||||
8. Fast paths are optimizations, not truth. Every optimized path must be able to return `NOT_APPLICABLE`, `LOW_CONFIDENCE`, `OUT_OF_DISTRIBUTION`, `CONFLICT` or `STALE` and escalate to broader reasoning/clarification.
|
||||
9. User corrections are strong learning evidence but are not automatically permanent rules. They create experience/reflection evidence and may later support revisable beliefs or skill changes.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Interaction remains canonical owner of conversations, utterances, original human intent and corrections.
|
||||
- Continuity remains canonical owner of HumanResponse, Attention and ProspectiveItem continuation state.
|
||||
- Capability remains canonical owner of capability implementations, gaps, usage observations and skill profiles.
|
||||
- Epistemics records evidence/reflection using typed refs only; it does not mutate other owners.
|
||||
- No external mutation, new authority, new provider adapter, deployment authority expansion or self-modifying code path is introduced.
|
||||
- Cognitive reflection, if later used, must go through Cognitive Gateway/Router and structured validation.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No LearningManager, reinforcement-learning platform, autonomous curriculum, skill dashboard, universal user profile, plugin marketplace or self-modifying development loop.
|
||||
- No automatic activation/promotion of cognitive models or capability implementations.
|
||||
- No new canonical owner.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Production evidence such as reminder acceptance, human correction, capability gaps, cognitive advisory outcomes and repeated capability failures can be represented consistently.
|
||||
- Validated fast paths can stay cheap, while stale/low-confidence/out-of-distribution cases can return to thinking instead of being forced into old rules.
|
||||
- Knowledge and experience survive skill implementation replacement.
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR-0017 — General Agent Delegation and Codex Bridge V1
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000069
|
||||
|
||||
## Context
|
||||
|
||||
Domovoy already has Human Interaction, Situation/context, optional durable Work/Plan, Capability registry/resolution, Continuity/Attention, HumanResponse, Experience/Reflection, Skill evolution, Secret Resolver and Cognitive Gateway/Router. The next step is not "make Codex the center", but to let Domovoy delegate suitable work to a general external intelligent agent while preserving canonical human intent, work semantics, authority boundaries and future portability to non-Codex agents.
|
||||
|
||||
The first real implementation is Codex for software-engineering work. However, a model is not an external agent. Cognitive models provide advice/candidates, while an external agent may inspect state, use tools, modify files, run tests, maintain a long-running task, checkpoint, resume and return evidence-bearing results.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Do not create a new canonical owner for DEV-000069.
|
||||
2. `capability` remains the canonical owner for agent implementation registration by reusing `CapabilityImplementation` with `Kind=AGENT`. A Codex bridge is one concrete implementation, not a new canonical capability identity.
|
||||
3. `work` remains the canonical owner for durable delegation lifecycle. DEV-000069 extends Work semantics from planning-only `DelegationCandidate` to real delegation execution state such as `Delegation`, `AgentRun`, `AgentCheckpoint` and `AgentResult`.
|
||||
4. `continuity` remains the canonical owner for human waiting/attention semantics. Human-dependent blockers or completed long-running delegations surface through Attention rather than a Codex-specific notification path.
|
||||
5. `epistemics` remains the canonical owner for delegation outcome evidence through `ExperienceRecord` and later reflection, using typed refs to Work, Capability, Artifact and Interaction state.
|
||||
6. `artifact` remains the place for durable artifact metadata/refs when an agent run produces file, diff, test, log, PR or deployment evidence. Work stores typed refs, not payload copies.
|
||||
7. `OriginalHumanIntent`, `Goal`, `Work`, `CapabilityRequirement` and `Delegation` must remain canonical semantic records. Agent-specific instruction or prompt text is only a derived adapter artifact with provenance that distinguishes:
|
||||
- what the human asked;
|
||||
- what Domovoy inferred;
|
||||
- what project/runtime context was added;
|
||||
- how completion will be verified.
|
||||
8. Execution-window exhaustion is a run lifecycle state, not automatic task failure. The same higher-level delegation may continue through later runs while preserving checkpoint/history.
|
||||
9. Codex transport/UI wording is adapter-local. Canonical state must stay valid if the coding agent is replaced by another future agent.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `interaction` owns the append-only human request/correction history and original intent traceability.
|
||||
- `situation` resolves project/repository context and active focus used for delegation context compilation.
|
||||
- `work` owns durable delegation requests, selected implementation refs, run lifecycle, checkpoint/result refs and verification gating before Work completion.
|
||||
- `capability` owns executor-neutral capability identity and concrete eligible implementations, including `AGENT` implementations such as Codex.
|
||||
- `continuity` owns waiting/attention/human-response continuation for delegation progress and genuine human blockers.
|
||||
- `epistemics` owns experience/reflection evidence derived from delegation outcomes.
|
||||
- `artifact` owns durable metadata for diffs/logs/checkpoints/results when they must survive outside a transient adapter response.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `SOFTWARE_ENGINEERING`, `MODIFY_CODE`, `INSPECT_REPOSITORY` and `RUN_TESTS` remain executor-neutral capability semantics.
|
||||
- `MODIFY_CODE != CODEX`.
|
||||
- Canonical Work/Delegation does not become a Codex prompt.
|
||||
- Secret access remains least-privilege through CredentialRef/Secret Resolver. Agent capability availability does not imply credential access or authority.
|
||||
- A completed agent run does not automatically mark higher-level Work complete. Verification must inspect available evidence.
|
||||
- Technical failures, execution-window exhaustion and human-dependent blockers are distinct lifecycle states.
|
||||
- `LLM-free` startup must continue to pass when Codex is unavailable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Domovoy can delegate suitable software-engineering work without forcing the human to author a giant Codex prompt.
|
||||
- The first production bridge can target Codex while leaving canonical semantics portable to future scientific, accounting, research or alternate coding agents.
|
||||
- Long-running agent work can checkpoint and continue across bounded execution windows.
|
||||
- Human chat remains simple, while operator/debug tooling can inspect the durable delegation/run/checkpoint/result state.
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-0018 — External audit and exploration mode
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-22
|
||||
Accepted under: ARCH-000012
|
||||
|
||||
## Decision
|
||||
|
||||
1. Domovoy may operate in a test-only `Exploration` profile when an ordinary
|
||||
local conversation or cognitive-provider recovery request is received.
|
||||
Exploration permits contextual reasoning, allowlisted read-only diagnostics,
|
||||
hypothesis generation, alternatives and a reversible next-step proposal.
|
||||
2. Exploration is not an authority grant. It does not grant access to raw
|
||||
secrets, executor credentials, a provider's unapproved data-egress path, or
|
||||
direct mutation of canonical or external state.
|
||||
3. Audit is an independently deployed, rebuildable projection of compact,
|
||||
redacted action/proposal events. It is not a canonical owner and never
|
||||
executes actions.
|
||||
4. An attributed external audit verdict is an Observation. A later Commit
|
||||
contract may require a current `ALLOW` Observation as an additional
|
||||
precondition for explicitly configured action classes. `REVIEW_REQUIRED`,
|
||||
`STOP`, missing or stale review do not authorize execution.
|
||||
5. Loss of the audit deployment does not remove exploration or approved
|
||||
read-only diagnostics. A review-governed external action remains a pending
|
||||
proposal; it is neither executed nor silently converted to failure.
|
||||
6. Audit payloads exclude raw credentials, secrets and private content. They
|
||||
carry stable identifiers/references, classifications, hashes and redacted
|
||||
summaries only.
|
||||
7. Initial delivery is staged: first semantic local dialogue and provider
|
||||
recovery, then capability-use telemetry, then audit-only projection. No
|
||||
existing executor action is review-governed until a later task names its
|
||||
class and recovery behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Local cognition remains useful during provider loss and can seek a recovery
|
||||
plan instead of returning canned availability text.
|
||||
- Higher-level external scrutiny is possible without creating a competing
|
||||
executor or a second source of semantic truth.
|
||||
- Commit/Executor, semantic ownership, append-only evidence and unknown
|
||||
outcome semantics remain invariants, so external review has attributable
|
||||
evidence to inspect.
|
||||
- The first implementation task may change local semantic routing and
|
||||
capability telemetry only; audit projection and Commit gating need their
|
||||
own explicit contracts and failure/rebuild tests.
|
||||
|
||||
## Revisit triggers
|
||||
|
||||
- The first external audit projection is deployed.
|
||||
- A concrete external action class is proposed for review gating.
|
||||
- Audit needs a data class beyond redacted references.
|
||||
- The Exploration profile needs a new privilege or canonical owner.
|
||||
@@ -4,4 +4,4 @@ Accepted architecture decisions live here. Normal implementation tasks may read
|
||||
|
||||
Use `ADR-XXXX-title.md` with: problem, evidence, alternatives, decision, rationale, consequences, validity conditions, revisit triggers, migration/rollback, status.
|
||||
|
||||
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary, and `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class.
|
||||
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary; `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class; and `ADR-0018-external-audit-exploration-mode.md`, the staged external-audit and test Exploration profile.
|
||||
|
||||
@@ -57,14 +57,29 @@
|
||||
"id": "mod.work",
|
||||
"owns": [
|
||||
"Goal",
|
||||
"HelpPath",
|
||||
"GoalLifecycle",
|
||||
"Task",
|
||||
"Decision"
|
||||
"Decision",
|
||||
"PlanCandidate",
|
||||
"WorkPlan",
|
||||
"PlanStep",
|
||||
"CapabilityRequirement",
|
||||
"DelegationCandidate",
|
||||
"Delegation",
|
||||
"AgentRun",
|
||||
"AgentCheckpoint",
|
||||
"AgentResult",
|
||||
"DelegationVerification"
|
||||
],
|
||||
"database_schema": "work",
|
||||
"boundary_class": "remote_capable",
|
||||
"may_depend_on": [
|
||||
"contracts.identity",
|
||||
"contracts.authority",
|
||||
"contracts.epistemics",
|
||||
"contracts.capability",
|
||||
"platform.ids",
|
||||
"platform.events"
|
||||
]
|
||||
},
|
||||
@@ -74,7 +89,10 @@
|
||||
"Observation",
|
||||
"Claim",
|
||||
"Belief",
|
||||
"Provenance"
|
||||
"Provenance",
|
||||
"SystemExpectation",
|
||||
"ExperienceRecord",
|
||||
"ReflectionCandidate"
|
||||
],
|
||||
"database_schema": "epistemics",
|
||||
"boundary_class": "remote_capable",
|
||||
@@ -88,7 +106,9 @@
|
||||
"id": "mod.continuity",
|
||||
"owns": [
|
||||
"Commitment",
|
||||
"ProspectiveItem"
|
||||
"ProspectiveItem",
|
||||
"AttentionItem",
|
||||
"HumanResponse"
|
||||
],
|
||||
"database_schema": "continuity",
|
||||
"boundary_class": "remote_capable",
|
||||
@@ -104,7 +124,16 @@
|
||||
"owns": [
|
||||
"Capability",
|
||||
"CapabilityBinding",
|
||||
"CapabilityHealth"
|
||||
"CapabilityHealth",
|
||||
"CapabilityImplementation",
|
||||
"CapabilityResolution",
|
||||
"CapabilitySelection",
|
||||
"CapabilityGap",
|
||||
"AcquisitionStrategyCandidate",
|
||||
"CapabilityUsageObservation",
|
||||
"SkillProfile",
|
||||
"SkillEvidence",
|
||||
"FastPathDecision"
|
||||
],
|
||||
"database_schema": "capability",
|
||||
"boundary_class": "remote_capable",
|
||||
|
||||
@@ -25,17 +25,17 @@ modules:
|
||||
|
||||
situation:
|
||||
id: mod.situation
|
||||
owns: [Situation, FocusBinding]
|
||||
owns: [Situation, FocusBinding, ProjectRegistry, ActiveProjectFocus]
|
||||
database_schema: situation
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.work, contracts.capability]
|
||||
may_depend_on: [contracts.identity, contracts.authority, contracts.work, contracts.capability]
|
||||
|
||||
work:
|
||||
id: mod.work
|
||||
owns: [Goal, Task, Decision]
|
||||
owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification]
|
||||
database_schema: work
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.authority, platform.events]
|
||||
may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events]
|
||||
|
||||
epistemics:
|
||||
id: mod.epistemics
|
||||
@@ -53,7 +53,7 @@ modules:
|
||||
|
||||
capability:
|
||||
id: mod.capability
|
||||
owns: [Capability, CapabilityBinding, CapabilityHealth]
|
||||
owns: [Capability, CapabilityBinding, CapabilityHealth, CapabilityImplementation, CapabilityResolution, CapabilitySelection, CapabilityGap, AcquisitionStrategyCandidate, CapabilityUsageObservation, SkillProfile, SkillEvidence, FastPathDecision]
|
||||
database_schema: capability
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.authority, contracts.artifact]
|
||||
@@ -81,11 +81,12 @@ platform:
|
||||
- platform.inbox
|
||||
- platform.telemetry
|
||||
- platform.errors
|
||||
- platform.secrets
|
||||
rule: "platform owns mechanisms, never domain meaning"
|
||||
|
||||
runtime_topology_v1:
|
||||
core_runtime:
|
||||
modules: [identity, situation, work, epistemics, capability, artifact]
|
||||
modules: [identity, situation, work, epistemics, capability, artifact, interaction]
|
||||
continuity_runtime:
|
||||
modules: [continuity]
|
||||
commit_runtime:
|
||||
@@ -107,6 +108,7 @@ storage_topology_v1:
|
||||
capability: capability_role
|
||||
commit: commit_role
|
||||
artifact: artifact_role
|
||||
interaction: interaction_role
|
||||
cross_owner_fk: forbidden_by_default
|
||||
cross_owner_write: forbidden
|
||||
cross_owner_application_join: forbidden
|
||||
@@ -142,6 +144,10 @@ protected_paths:
|
||||
- architecture/architecture-registry.yaml
|
||||
- architecture/adrs/**
|
||||
- ci/architecture/**
|
||||
- .gitea/workflows/ci.yml
|
||||
- cmd/devctl/**
|
||||
- dev/ACTIVE_TASK.json
|
||||
- ci/architecture/**
|
||||
- security/**
|
||||
|
||||
default_feature_task_authority:
|
||||
|
||||
@@ -27,28 +27,28 @@ modules:
|
||||
|
||||
work:
|
||||
id: mod.work
|
||||
owns: [Goal, Task, Decision]
|
||||
owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification]
|
||||
database_schema: work
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.authority, platform.events]
|
||||
may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events]
|
||||
|
||||
epistemics:
|
||||
id: mod.epistemics
|
||||
owns: [Observation, Claim, Belief, Provenance]
|
||||
owns: [Observation, Claim, Belief, Provenance, SystemExpectation, ExperienceRecord, ReflectionCandidate]
|
||||
database_schema: epistemics
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.artifact, platform.events]
|
||||
|
||||
continuity:
|
||||
id: mod.continuity
|
||||
owns: [Commitment, ProspectiveItem]
|
||||
owns: [Commitment, ProspectiveItem, AttentionItem, HumanResponse]
|
||||
database_schema: continuity
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
||||
|
||||
capability:
|
||||
id: mod.capability
|
||||
owns: [Capability, CapabilityBinding, CapabilityHealth]
|
||||
owns: [Capability, CapabilityBinding, CapabilityHealth, CapabilityImplementation, CapabilityResolution, CapabilitySelection, CapabilityGap, AcquisitionStrategyCandidate, CapabilityUsageObservation, SkillProfile, SkillEvidence, FastPathDecision]
|
||||
database_schema: capability
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.authority, contracts.artifact]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
)
|
||||
|
||||
type memoryStore struct{ writes int }
|
||||
|
||||
func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error {
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
enabled := flag.Bool("enable-canary", false, "explicitly enable the manual canary")
|
||||
implementation := flag.String("implementation", "fake-worker", "allowlisted deterministic implementation")
|
||||
postgresMode := flag.Bool("postgres", false, "use the Work PostgreSQL repository instead of in-memory storage")
|
||||
flag.Parse()
|
||||
|
||||
now := time.UnixMilli(1_700_000_000_000)
|
||||
intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-1", IdempotencyKey: "manual-idempotency-1", DelegationID: "manual-delegation-1", RunID: "manual-run-1", ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-1", WorktreeID: "manual-worktree-1", VerificationPlanRef: "manual-verification-1", CreatedAtUnixMillis: now.UnixMilli()}
|
||||
var store ports.ExecutionStore = &memoryStore{}
|
||||
var closeStore func()
|
||||
if *postgresMode {
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
store = repository
|
||||
closeStore = repository.Close
|
||||
}
|
||||
if closeStore != nil {
|
||||
defer closeStore()
|
||||
}
|
||||
result, err := application.ExecuteCanaryDryRun(context.Background(), store, application.CanaryConfig{Enabled: *enabled, ImplementationID: "fake-worker", MaxDispatches: 1}, 0, intent, "manual-worker", 1, now, 15*time.Minute)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
output := struct {
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
RunID string `json:"run_id"`
|
||||
CallbackCount int `json:"callback_count"`
|
||||
StoreWrites int `json:"store_writes"`
|
||||
}{result.Intent.DispatchID, string(result.Intent.RunID), result.CallbackCount(), 0}
|
||||
if memory, ok := store.(*memoryStore); ok {
|
||||
output.StoreWrites = memory.writes
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/agentbridge"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
|
||||
statusAddr := flag.String("status-addr", ":8090", "internal status HTTP address")
|
||||
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
|
||||
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration")
|
||||
pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval")
|
||||
once := flag.Bool("once", false, "run one cycle and exit")
|
||||
flag.Parse()
|
||||
|
||||
password, err := resolveDatabasePassword("WORK_POSTGRES_PASSWORD", "WORK_POSTGRES_CREDENTIAL_REF", secretcontracts.RuntimeConsumerWorkWorker)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer repository.Close()
|
||||
var executor ports.AgentExecutor
|
||||
if bridgeURL := os.Getenv("AGENT_EXECUTION_BRIDGE_URL"); bridgeURL != "" {
|
||||
executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "agent execution bridge is not configured; dispatch claims are disabled; recovery-only mode")
|
||||
}
|
||||
go func() {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil)); serveErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr)
|
||||
}
|
||||
}()
|
||||
ctx := context.Background()
|
||||
for {
|
||||
var result application.WorkerCycleResult
|
||||
var cycleErr error
|
||||
if executor == nil {
|
||||
result, cycleErr = application.RecoverOnlyWorkerCycle(ctx, repository, time.Now().UTC(), *staleAfter)
|
||||
} else {
|
||||
result, cycleErr = application.ExecuteWorkerCycleWithExecutor(ctx, repository, executor, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
|
||||
}
|
||||
if cycleErr != nil {
|
||||
fmt.Fprintln(os.Stderr, cycleErr)
|
||||
if *once {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if cycleErr == nil && (result.Completed || result.RecoveredClaims > 0) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
if *once {
|
||||
return
|
||||
}
|
||||
if err := repository.WaitForDispatch(ctx, *pollInterval); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: consumer, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type workerStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ExecutorConfigured bool `json:"executor_configured"`
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func workerStatusHandler(executorConfigured bool) http.Handler {
|
||||
status := workerStatusResponse{
|
||||
Runtime: "agent-worker",
|
||||
ExecutorConfigured: executorConfigured,
|
||||
Mode: "recovery-only",
|
||||
Ready: false,
|
||||
Reason: "AGENT_EXECUTION_BRIDGE_URL is not configured",
|
||||
}
|
||||
if executorConfigured {
|
||||
status.Mode = "execution"
|
||||
status.Ready = true
|
||||
status.Reason = "typed agent execution bridge configured"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/readyz" && !status.Ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || value.Ready || value.Mode != "recovery-only" || value.ExecutorConfigured {
|
||||
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerReadyzRequiresExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("configured readyz status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
realityevaluation "git.cin.su/m/Assistant/tests/reality_evaluation"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Providers []providerCandidate `json:"providers"`
|
||||
}
|
||||
|
||||
type providerCandidate struct {
|
||||
ProviderInstanceID string `json:"provider_instance_id"`
|
||||
ProviderKind string `json:"provider_kind"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Candidates []modelCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
type modelCandidate struct {
|
||||
ProviderModelID string `json:"provider_model_id"`
|
||||
ModelDescriptorID string `json:"model_descriptor_id"`
|
||||
ModelRevision int64 `json:"model_revision"`
|
||||
BindingID string `json:"binding_id"`
|
||||
BindingRevision int64 `json:"binding_revision"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
manifestPath := flag.String("manifest", "", "benchmark manifest JSON")
|
||||
suitePath := flag.String("suite", "", "cognitive evaluation suite JSON")
|
||||
outputPath := flag.String("output", "", "output artifact JSON")
|
||||
observedAt := flag.String("observed-at", "2026-08-17T12:00:00Z", "RFC3339 timestamp for reproducible artifact generation")
|
||||
timeout := flag.Duration("case-timeout", 30*time.Second, "deadline for each case")
|
||||
flag.Parse()
|
||||
if *manifestPath == "" || *suitePath == "" || *outputPath == "" || *timeout <= 0 {
|
||||
fatal(errors.New("manifest, suite, output and positive case-timeout are required"))
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, *observedAt)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
input, err := os.ReadFile(*manifestPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
var plan manifest
|
||||
if err := json.Unmarshal(input, &plan); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if len(plan.Providers) == 0 || len(plan.Providers[0].Candidates) == 0 {
|
||||
fatal(errors.New("manifest has no candidates"))
|
||||
}
|
||||
suite, err := realityevaluation.LoadCognitiveSuite(*suitePath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
provider := plan.Providers[0]
|
||||
candidate := provider.Candidates[0]
|
||||
adapter, err := newProvider(provider)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
responses := map[string]string{}
|
||||
for index, item := range suite.Cases {
|
||||
caseCtx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
response, err := adapter.Advise(caseCtx, cognitive.AdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: cognitive.RequestID(fmt.Sprintf("cognitive-eval-%d", index)),
|
||||
ActorID: "benchmark-actor",
|
||||
PrincipalID: "domovoy-creator-principal",
|
||||
ScopeID: "benchmark-public-synthetic",
|
||||
Message: item.Prompt,
|
||||
Locale: "ru-RU",
|
||||
MaxOutputBytes: 4096,
|
||||
ProviderBindingID: capability.CapabilityBindingID(candidate.BindingID),
|
||||
ProviderBindingRevision: candidate.BindingRevision,
|
||||
ProviderInstanceID: capability.ProviderInstanceID(provider.ProviderInstanceID),
|
||||
ModelDescriptorID: capability.ModelDescriptorID(candidate.ModelDescriptorID),
|
||||
ProviderModelID: capability.ProviderModelID(candidate.ProviderModelID),
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
responses[item.ID] = "ADAPTER_ERROR: " + err.Error()
|
||||
continue
|
||||
}
|
||||
responses[item.ID] = response.AdvisoryText
|
||||
}
|
||||
artifact, err := realityevaluation.EvaluateCognitiveSuite(
|
||||
suite,
|
||||
provider.ProviderInstanceID,
|
||||
candidate.ProviderModelID,
|
||||
candidate.ModelDescriptorID,
|
||||
candidate.BindingID,
|
||||
candidate.BindingRevision,
|
||||
responses,
|
||||
at,
|
||||
)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
payload, err := json.MarshalIndent(artifact, "", " ")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.WriteFile(*outputPath, payload, 0o600); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("artifact=%s total_cases=%d output=%s\n", artifact.ArtifactID, artifact.TotalCases, *outputPath)
|
||||
}
|
||||
|
||||
func newProvider(candidate providerCandidate) (models.LocalProvider, error) {
|
||||
switch candidate.ProviderKind {
|
||||
case "openai-compatible-chat-v1":
|
||||
return models.NewOpenAICompatibleLocalAdapter(candidate.Endpoint, nil)
|
||||
case "ollama-v1":
|
||||
return models.NewOllamaAdapter(candidate.Endpoint, nil)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider kind %q", candidate.ProviderKind)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim
|
||||
artifact.Corpus = append(artifact.Corpus, corpusRecord{ID: item.id, Prompt: item.prompt, PromptSHA256: digest(item.prompt), Critical: item.critical})
|
||||
}
|
||||
for _, provider := range plan.Providers {
|
||||
adapter, err := newProvider(provider)
|
||||
adapter, err := newProvider(provider, timeout)
|
||||
if err != nil {
|
||||
return benchmarkArtifact{}, err
|
||||
}
|
||||
@@ -158,10 +158,10 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func newProvider(candidate providerCandidate) (models.LocalProvider, error) {
|
||||
func newProvider(candidate providerCandidate, timeout time.Duration) (models.LocalProvider, error) {
|
||||
switch candidate.ProviderKind {
|
||||
case "openai-compatible-chat-v1":
|
||||
return models.NewOpenAICompatibleLocalAdapter(candidate.Endpoint, nil)
|
||||
return models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(candidate.Endpoint, "", nil, timeout)
|
||||
case "ollama-v1":
|
||||
return models.NewOllamaAdapter(candidate.Endpoint, nil)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
realityevaluation "git.cin.su/m/Assistant/tests/reality_evaluation"
|
||||
)
|
||||
|
||||
func main() {
|
||||
suitePath := flag.String("suite", "", "path to versioned reality-evaluation suite JSON")
|
||||
outputPath := flag.String("output", "", "path to JSON artifact output")
|
||||
observedAt := flag.String("observed-at", "2026-08-17T12:00:00Z", "RFC3339 timestamp for reproducible artifact generation")
|
||||
flag.Parse()
|
||||
if *suitePath == "" || *outputPath == "" {
|
||||
fatal(fmt.Errorf("suite and output are required"))
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, *observedAt)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
suite, err := realityevaluation.LoadSuite(*suitePath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
artifact, err := realityevaluation.RunSuite(suite, at)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
payload, err := json.MarshalIndent(artifact, "", " ")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.WriteFile(*outputPath, payload, 0o600); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
reportPath := strings.TrimSuffix(*outputPath, filepath.Ext(*outputPath)) + ".md"
|
||||
if err := os.WriteFile(reportPath, []byte(realityevaluation.MarkdownReport(artifact)), 0o600); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("artifact=%s total_cases=%d output=%s report=%s\n", artifact.ArtifactID, artifact.TotalCases, *outputPath, reportPath)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
type httpAdmissionClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newHTTPAdmissionClient(baseURL string, client *http.Client) *httpAdmissionClient {
|
||||
return &httpAdmissionClient{baseURL: strings.TrimRight(baseURL, "/"), client: client}
|
||||
}
|
||||
|
||||
func (c *httpAdmissionClient) Admit(ctx context.Context, leaseID string, request capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
payload, err := json.Marshal(struct {
|
||||
LeaseID string `json:"lease_id"`
|
||||
Request capability.AdmissionRequest `json:"request"`
|
||||
}{leaseID, request})
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, fmt.Errorf("admission status %d", resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Decision capability.AdmissionDecision `json:"decision"`
|
||||
Lease capability.AdmissionLease `json:"lease"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return result.Decision, result.Lease, nil
|
||||
}
|
||||
|
||||
func (c *httpAdmissionClient) Release(ctx context.Context, lease capability.AdmissionLease) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission/"+lease.ID+"/release", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("admission release status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type admissionTestProvider struct{ calls int }
|
||||
|
||||
func (p *admissionTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
p.calls++
|
||||
return cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted}, nil
|
||||
}
|
||||
|
||||
type admissionTestClient struct {
|
||||
decision capability.AdmissionDecision
|
||||
releases int
|
||||
}
|
||||
|
||||
func (c *admissionTestClient) Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
return c.decision, capability.AdmissionLease{ID: "lease-1", PolicyID: "policy-1"}, nil
|
||||
}
|
||||
func (c *admissionTestClient) Release(context.Context, capability.AdmissionLease) error {
|
||||
c.releases++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInvokeAdmittedDoesNotCallProviderWhenRejected(t *testing.T) {
|
||||
provider := &admissionTestProvider{}
|
||||
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: "policy-1"}}
|
||||
gateway := cognitiveGateway{admission: client}
|
||||
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
|
||||
if err != nil || response.FinishReason != cognitive.FinishRateLimited || provider.calls != 0 || client.releases != 0 {
|
||||
t.Fatalf("rejected admission leaked provider call: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeAdmittedReleasesDurableLeaseAfterProvider(t *testing.T) {
|
||||
provider := &admissionTestProvider{}
|
||||
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: "policy-1"}}
|
||||
gateway := cognitiveGateway{admission: client}
|
||||
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
|
||||
if err != nil || response.FinishReason != cognitive.FinishCompleted || provider.calls != 1 || client.releases != 1 {
|
||||
t.Fatalf("admitted lease was not released: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
discovered, probe := registration.Discovery.Discover(r.Context())
|
||||
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
|
||||
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, AuthMode: registration.AuthMode, BillingMode: registration.BillingMode, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: registration.DataPolicy, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
|
||||
for _, model := range discovered {
|
||||
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
fleetdomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type fleetEndpoint struct {
|
||||
ProviderID string
|
||||
ModelID string
|
||||
Tier capability.FleetTier
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
type fleetStatusResponse struct {
|
||||
Readiness capability.FleetReadiness `json:"readiness"`
|
||||
Preferred capability.FleetTier `json:"preferred_tier"`
|
||||
Selected capability.FleetTier `json:"selected_tier,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
members := make([]capability.FleetMember, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
members = append(members, member)
|
||||
continue
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil)
|
||||
if err == nil {
|
||||
for name, value := range endpoint.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
probeClient := endpoint.Client
|
||||
if probeClient == nil {
|
||||
probeClient = client
|
||||
}
|
||||
response, requestErr := probeClient.Do(request)
|
||||
if requestErr == nil {
|
||||
response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
member.Ready, member.Reason = true, "AVAILABLE"
|
||||
} else {
|
||||
member.Reason = fmt.Sprintf("status=%s", response.Status)
|
||||
}
|
||||
} else {
|
||||
member.Reason = requestErr.Error()
|
||||
}
|
||||
}
|
||||
members = append(members, member)
|
||||
}
|
||||
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
|
||||
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
|
||||
if err == nil {
|
||||
response.Selected, err = fleetdomain.SelectFleetTier(readiness, preferred)
|
||||
}
|
||||
if err != nil {
|
||||
response.Error = err.Error()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if response.Selected == "" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
}
|
||||
|
||||
func fleetContext(ctx context.Context) context.Context { return ctx }
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
func TestFleetReadinessSelectsBestAvailablePrimaryTierBeforeLifeboat(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierHeavy, []fleetEndpoint{
|
||||
{ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL},
|
||||
{ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium},
|
||||
{ProviderID: "heavy", ModelID: "heavy-model", Tier: capability.FleetTierHeavy},
|
||||
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: server.URL},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"FAST"`) || !strings.Contains(recorder.Body.String(), `"tier":"MEDIUM"`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer fleet-key" {
|
||||
t.Fatalf("authorization=%q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierFast, []fleetEndpoint{{
|
||||
ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL,
|
||||
Headers: map[string]string{"Authorization": "Bearer fleet-key"},
|
||||
}})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"ready":true`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
@@ -24,11 +26,19 @@ type bindingResolver interface {
|
||||
RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error)
|
||||
}
|
||||
|
||||
type admissionClient interface {
|
||||
Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error)
|
||||
Release(context.Context, capability.AdmissionLease) error
|
||||
}
|
||||
|
||||
type providerRegistration struct {
|
||||
ProviderInstanceID capability.ProviderInstanceID
|
||||
Kind capability.ProviderKind
|
||||
EndpointRef capability.EndpointBindingRef
|
||||
CredentialRef capability.CredentialRef
|
||||
AuthMode capability.AuthMode
|
||||
BillingMode capability.BillingMode
|
||||
DataPolicy capability.DataPolicy
|
||||
Provider advisoryProvider
|
||||
Discovery models.LocalProvider
|
||||
}
|
||||
@@ -36,9 +46,12 @@ type providerRegistration struct {
|
||||
type cognitiveGateway struct {
|
||||
resolver bindingResolver
|
||||
providers map[capability.ProviderKind][]providerRegistration
|
||||
builtinLocal *providerRegistration
|
||||
externalRecovery []providerRegistration
|
||||
now func() time.Time
|
||||
allowCanary bool
|
||||
canaryMaxOutputBytes int
|
||||
admission admissionClient
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) policyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -71,10 +84,10 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
if g.allowCanary && g.canaryMaxOutputBytes > 0 && maxOutputBytes > g.canaryMaxOutputBytes {
|
||||
maxOutputBytes = g.canaryMaxOutputBytes
|
||||
}
|
||||
requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes)}
|
||||
requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes), Objective: selectionObjectiveForMeaning(request.Meaning, request.Message)}
|
||||
selected, err := g.resolver.Resolve(r.Context(), requirement)
|
||||
if err != nil {
|
||||
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
|
||||
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
|
||||
@@ -83,14 +96,14 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
provider := g.provider(selected)
|
||||
if provider == nil {
|
||||
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
|
||||
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
started := g.now()
|
||||
response, invokeErr := provider.Advise(r.Context(), invocation)
|
||||
response, invokeErr := g.invokeAdmitted(r.Context(), string(request.RequestID), provider, invocation, maxOutputBytes)
|
||||
if invokeErr != nil {
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
|
||||
}
|
||||
g.observe(r.Context(), selected, response, started)
|
||||
if fallbackEligible(response.FinishReason) {
|
||||
@@ -102,17 +115,142 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
fallbackRequest.ProviderBindingID, fallbackRequest.ProviderBindingRevision = fallback.Binding.ID, fallback.Binding.Revision
|
||||
fallbackRequest.ProviderInstanceID, fallbackRequest.ModelDescriptorID, fallbackRequest.ProviderModelID = fallback.Provider.ID, fallback.Model.ID, fallback.Model.ProviderModelID
|
||||
fallbackStarted := g.now()
|
||||
fallbackResponse, fallbackErr := fallbackProvider.Advise(r.Context(), fallbackRequest)
|
||||
fallbackResponse, fallbackErr := g.invokeAdmitted(r.Context(), string(request.RequestID)+":fallback", fallbackProvider, fallbackRequest, maxOutputBytes)
|
||||
if fallbackErr != nil {
|
||||
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
|
||||
}
|
||||
g.observe(r.Context(), fallback, fallbackResponse, fallbackStarted)
|
||||
response = fallbackResponse
|
||||
}
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishCompleted && fallbackEligible(response.FinishReason) {
|
||||
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage})
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, provider advisoryProvider, invocation cognitive.AdvisoryRequest, maxOutputBytes int) (cognitive.AdvisoryResponse, error) {
|
||||
var lease capability.AdmissionLease
|
||||
if g.admission != nil {
|
||||
decision, admittedLease, err := g.admission.Admit(ctx, leaseID, capability.AdmissionRequest{InputBytes: len(invocation.Message), MaxOutputBytes: maxOutputBytes, DeadlineMillis: 0})
|
||||
if err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
if decision.State != capability.AdmissionAdmitted {
|
||||
return cognitive.AdvisoryResponse{Version: invocation.Version, RequestID: invocation.RequestID, ProviderBindingID: invocation.ProviderBindingID, ProviderBindingRevision: invocation.ProviderBindingRevision, FinishReason: cognitive.FinishRateLimited}, nil
|
||||
}
|
||||
lease = admittedLease
|
||||
defer func() { _ = g.admission.Release(ctx, lease) }()
|
||||
}
|
||||
return provider.Advise(ctx, invocation)
|
||||
}
|
||||
|
||||
func selectionObjectiveForMessage(message string) capability.SelectionObjective {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "дешев") || strings.Contains(lower, "бесплат") || strings.Contains(lower, "эконом") || strings.Contains(lower, "cost") || strings.Contains(lower, "cheap") {
|
||||
return capability.SelectionEconomy
|
||||
}
|
||||
if strings.Contains(lower, "быстр") || strings.Contains(lower, "срочно") || strings.Contains(lower, "статус") || strings.Contains(lower, "quick") || strings.Contains(lower, "fast") {
|
||||
return capability.SelectionSpeed
|
||||
}
|
||||
if strings.Contains(lower, "сложн") || strings.Contains(lower, "анализ") || strings.Contains(lower, "код") || strings.Contains(lower, "план") || strings.Contains(lower, "глубок") || strings.Contains(lower, "reason") || strings.Contains(lower, "code") {
|
||||
return capability.SelectionQuality
|
||||
}
|
||||
return capability.SelectionBalanced
|
||||
}
|
||||
|
||||
func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string) capability.SelectionObjective {
|
||||
if meaning.CapabilityKind != "" || len(meaning.CapabilityHints) > 0 || meaning.SpeechAct != "" || meaning.RequestedOutcome != "" {
|
||||
kinds := append([]capability.CapabilityKind{meaning.CapabilityKind}, meaning.CapabilityHints...)
|
||||
for _, kind := range kinds {
|
||||
switch kind {
|
||||
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindVerifyFact, capability.CapabilityKindInspectProject, capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs:
|
||||
return capability.SelectionQuality
|
||||
case capability.CapabilityKindScheduleReminder:
|
||||
return capability.SelectionSpeed
|
||||
}
|
||||
}
|
||||
if meaning.RequestedOutcome != "" || meaning.SpeechAct == "REQUEST_TO_INVESTIGATE" || meaning.SpeechAct == "DIRECTIVE" {
|
||||
return capability.SelectionQuality
|
||||
}
|
||||
if meaning.ConfidenceMillis > 0 {
|
||||
return capability.SelectionBalanced
|
||||
}
|
||||
}
|
||||
return selectionObjectiveForMessage(message)
|
||||
}
|
||||
|
||||
func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse {
|
||||
text := "Я рядом и продолжаю работу во встроенном режиме Домового. Внешний когнитивный маршрут сейчас недоступен, поэтому опираюсь на уже установленный смысл сообщения и локальные возможности."
|
||||
switch request.Meaning.InformationRequestKind {
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
|
||||
case interaction.InformationRequestCognitiveChannelStatus:
|
||||
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
|
||||
case interaction.InformationRequestCognitiveRecovery:
|
||||
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
|
||||
default:
|
||||
switch request.Meaning.SpeechAct {
|
||||
case "REQUEST_TO_INVESTIGATE":
|
||||
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
|
||||
case "QUESTION":
|
||||
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
|
||||
}
|
||||
}
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
|
||||
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
|
||||
started := g.now()
|
||||
response, err := g.builtinLocal.Provider.Advise(ctx, invocation)
|
||||
if response.RequestID == "" {
|
||||
response.RequestID = request.RequestID
|
||||
}
|
||||
if response.Version == "" {
|
||||
response.Version = request.Version
|
||||
}
|
||||
if err != nil {
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
}
|
||||
g.observeBuiltin(ctx, g.builtinLocal, response, started)
|
||||
if err == nil && response.FinishReason == cognitive.FinishCompleted && strings.TrimSpace(response.AdvisoryText) != "" {
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}
|
||||
}
|
||||
}
|
||||
if request.EgressAcknowledged {
|
||||
if response, ok := g.recoverExternalAdvisory(ctx, request, maxOutputBytes); ok {
|
||||
return response
|
||||
}
|
||||
}
|
||||
return builtinAdvisory(request)
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) recoverExternalAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) (cognitive.GatewayAdvisoryResponse, bool) {
|
||||
for _, candidate := range g.externalRecovery {
|
||||
if candidate.Provider == nil || candidate.Discovery == nil {
|
||||
continue
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
modelsFound, probe := candidate.Discovery.Discover(probeCtx)
|
||||
cancel()
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
continue
|
||||
}
|
||||
model := modelsFound[0]
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "recovery-external-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: capability.ProviderInstanceID("recovery-" + string(candidate.ProviderInstanceID)), ModelDescriptorID: capability.ModelDescriptorID("recovery-model-" + string(model.ProviderModelID)), ProviderModelID: model.ProviderModelID}
|
||||
response, err := candidate.Provider.Advise(ctx, invocation)
|
||||
if err == nil && response.FinishReason == cognitive.FinishCompleted && strings.TrimSpace(response.AdvisoryText) != "" {
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}, true
|
||||
}
|
||||
}
|
||||
return cognitive.GatewayAdvisoryResponse{}, false
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) observe(ctx context.Context, selected capability.ResolvedCognitiveBinding, response cognitive.AdvisoryResponse, started time.Time) {
|
||||
usage := response.Usage
|
||||
observationID, idErr := ids.New()
|
||||
@@ -132,6 +270,21 @@ func (g cognitiveGateway) observe(ctx context.Context, selected capability.Resol
|
||||
}
|
||||
}
|
||||
|
||||
// observeBuiltin gives the always-available local provider the same evidence
|
||||
// path as a resolved binding. The identity is stable and contains references
|
||||
// only; no endpoint, credential, prompt, or response text is persisted here.
|
||||
func (g cognitiveGateway) observeBuiltin(ctx context.Context, registration *providerRegistration, response cognitive.AdvisoryResponse, started time.Time) {
|
||||
if registration == nil {
|
||||
return
|
||||
}
|
||||
selected := capability.ResolvedCognitiveBinding{
|
||||
Binding: capability.CognitiveBinding{ID: "builtin-local-binding-v1", Revision: 1},
|
||||
Provider: capability.ProviderInstance{ID: "builtin-local-lifeboat-v1", Revision: 1, Kind: registration.Kind},
|
||||
Model: capability.ModelDescriptor{ID: "builtin-local-model-v1", Revision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ProviderModelID: "domovoy-lifeboat"},
|
||||
}
|
||||
g.observe(ctx, selected, response, started)
|
||||
}
|
||||
|
||||
func fallbackEligible(reason cognitive.FinishReason) bool {
|
||||
switch reason {
|
||||
case cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishTimeout, cognitive.FinishDegraded:
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
type registryStub struct {
|
||||
@@ -114,7 +115,45 @@ func TestCanaryOutputLimitIsClamped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) {
|
||||
func TestSelectionObjectiveForMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
message string
|
||||
objective capability.SelectionObjective
|
||||
}{
|
||||
{"Сделай глубокий анализ кода", capability.SelectionQuality},
|
||||
{"Нужен быстрый статус", capability.SelectionSpeed},
|
||||
{"Выбери бесплатную модель", capability.SelectionEconomy},
|
||||
{"Помоги сформулировать ответ", capability.SelectionBalanced},
|
||||
}
|
||||
for _, test := range cases {
|
||||
if actual := selectionObjectiveForMessage(test.message); actual != test.objective {
|
||||
t.Errorf("message=%q objective=%q, want %q", test.message, actual, test.objective)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionObjectiveUsesTrustedMeaningBeforeText(t *testing.T) {
|
||||
meaning := cognitive.TaskMeaning{CapabilityKind: capability.CapabilityKindModifyCode, SpeechAct: "DIRECTIVE"}
|
||||
if actual := selectionObjectiveForMeaning(meaning, "сделай быстро и дёшево"); actual != capability.SelectionQuality {
|
||||
t.Fatalf("objective=%q, want quality", actual)
|
||||
}
|
||||
if actual := selectionObjectiveForMeaning(cognitive.TaskMeaning{}, "сделай быстро"); actual != capability.SelectionSpeed {
|
||||
t.Fatalf("fallback objective=%q, want speed", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionObjectiveForInterpretationUsesSemanticSignals(t *testing.T) {
|
||||
request := cognitive.GatewayInterpretationRequest{RequestedOutcome: "сравнить варианты", DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "QUESTION"}
|
||||
if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionQuality {
|
||||
t.Fatalf("objective=%q, want quality", actual)
|
||||
}
|
||||
request = cognitive.GatewayInterpretationRequest{DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "ASSERTION"}
|
||||
if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionSpeed {
|
||||
t.Fatalf("objective=%q, want speed", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"unused": {{Provider: provider}}}, now: time.Now}
|
||||
@@ -125,11 +164,63 @@ func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) {
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishProviderUnavailable || provider.called {
|
||||
if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishCompleted || result.AdvisoryText == "" || provider.called {
|
||||
t.Fatalf("status=%d result=%+v provider_called=%t", response.Code, result, provider.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
|
||||
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
|
||||
response := builtinAdvisory(request)
|
||||
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
|
||||
t.Fatalf("unexpected recovery response: %q", response.AdvisoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
|
||||
builtin := providerRegistration{ProviderInstanceID: "local-lifeboat", Kind: "openai-compatible-chat-v1", EndpointRef: "local-lifeboat-v1", Provider: provider}
|
||||
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest()))
|
||||
response := httptest.NewRecorder()
|
||||
gateway.advisoryHandler(response, request)
|
||||
if response.Code != http.StatusOK || !provider.called || !strings.Contains(response.Body.String(), "локальный ответ") {
|
||||
t.Fatalf("status=%d provider_called=%t body=%s", response.Code, provider.called, response.Body.String())
|
||||
}
|
||||
if registry.observation == nil || registry.observation.BindingID != "builtin-local-binding-v1" || registry.observation.ProviderInstanceID != "builtin-local-lifeboat-v1" || registry.observation.ModelDescriptorID != "builtin-local-model-v1" || registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishCompleted) {
|
||||
t.Fatalf("built-in usage observation=%+v", registry.observation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInProviderFailureIsObservedWithoutBlockingFallback(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{err: errors.New("local timeout")}
|
||||
builtin := providerRegistration{Kind: "ollama-v1", Provider: provider}
|
||||
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest()))
|
||||
response := httptest.NewRecorder()
|
||||
gateway.advisoryHandler(response, request)
|
||||
if response.Code != http.StatusOK || registry.observation == nil {
|
||||
t.Fatalf("status=%d observation=%+v body=%s", response.Code, registry.observation, response.Body.String())
|
||||
}
|
||||
if registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishInvalidProviderResponse) || registry.observation.FailureType != capability.CognitiveFailureType(cognitive.FinishInvalidProviderResponse) {
|
||||
t.Fatalf("failure observation=%+v", registry.observation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInInterpretationIsObserved(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted, AdvisoryText: `{"primary_speech_act":"QUESTION","secondary_speech_act":"","confidence_millis":900,"applicability":"APPLICABLE","requested_outcome":"","needs_cognition":false,"clarification":"NOT_REQUIRED","selection_reason":"local","capability_hints":[]}`}}
|
||||
builtin := providerRegistration{Kind: "openai-compatible-chat-v1", Provider: provider}
|
||||
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
|
||||
request := cognitive.GatewayInterpretationRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "interpretation-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "что доступно", MaxOutputBytes: 4096, DeterministicSpeechAct: "QUESTION", DeterministicApplicability: cognitive.InterpretationApplicable}
|
||||
result := gateway.interpretWithBuiltin(context.Background(), request, request.MaxOutputBytes)
|
||||
if result.FinishReason != cognitive.FinishCompleted || registry.observation == nil || registry.observation.RequestID != "interpretation-1" {
|
||||
t.Fatalf("result=%+v observation=%+v", result, registry.observation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLossBetweenRoutingAndInvocationFallsBackOnce(t *testing.T) {
|
||||
primary := gatewaySelection()
|
||||
primary.Binding.ID = "gpu-binding"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request cognitive.GatewayInterpretationRequest
|
||||
if !decodeJSON(w, r, 80<<10, &request) {
|
||||
return
|
||||
}
|
||||
if request.Version != cognitive.CreatorAdvisoryV1 || request.RequestID == "" || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || strings.TrimSpace(request.Message) == "" || request.MaxOutputBytes < 1 {
|
||||
http.Error(w, "invalid interpretation request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
maxOutputBytes := request.MaxOutputBytes
|
||||
if g.allowCanary && g.canaryMaxOutputBytes > 0 && maxOutputBytes > g.canaryMaxOutputBytes {
|
||||
maxOutputBytes = g.canaryMaxOutputBytes
|
||||
}
|
||||
requirement := capability.CognitiveRequirement{
|
||||
CapabilityID: creatorAdvisoryCapabilityID,
|
||||
OwnerPrincipalID: request.PrincipalID,
|
||||
DataPolicy: request.DataPolicy,
|
||||
RequiredModalities: []capability.ModelModality{capability.ModalityText},
|
||||
RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory},
|
||||
AllowCanary: g.allowCanary,
|
||||
InputBytes: int64(len(request.Message)),
|
||||
MaxOutputBytes: int64(maxOutputBytes),
|
||||
Objective: selectionObjectiveForInterpretation(request),
|
||||
}
|
||||
selected, err := g.resolver.Resolve(r.Context(), requirement)
|
||||
if err != nil {
|
||||
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
provider := g.provider(selected)
|
||||
if provider == nil {
|
||||
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
invocation := cognitive.AdvisoryRequest{
|
||||
Version: request.Version,
|
||||
RequestID: request.RequestID,
|
||||
ActorID: request.ActorID,
|
||||
PrincipalID: request.PrincipalID,
|
||||
ScopeID: request.ScopeID,
|
||||
Message: interpretationPrompt(request),
|
||||
Locale: request.Locale,
|
||||
MaxOutputBytes: maxOutputBytes,
|
||||
ProviderBindingID: selected.Binding.ID,
|
||||
ProviderBindingRevision: selected.Binding.Revision,
|
||||
ProviderInstanceID: selected.Provider.ID,
|
||||
ModelDescriptorID: selected.Model.ID,
|
||||
ProviderModelID: selected.Model.ProviderModelID,
|
||||
}
|
||||
started := g.now()
|
||||
response, invokeErr := provider.Advise(r.Context(), invocation)
|
||||
if invokeErr != nil {
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
}
|
||||
g.observe(r.Context(), selected, response, started)
|
||||
if response.FinishReason != cognitive.FinishCompleted {
|
||||
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: response.FinishReason, Usage: response.Usage})
|
||||
return
|
||||
}
|
||||
candidate, ok := parseInterpretationCandidate(response.AdvisoryText)
|
||||
if !ok {
|
||||
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishInvalidStructuredOutput, Usage: response.Usage})
|
||||
return
|
||||
}
|
||||
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage})
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) interpretWithBuiltin(ctx context.Context, request cognitive.GatewayInterpretationRequest, maxOutputBytes int) cognitive.GatewayInterpretationResponse {
|
||||
if g.builtinLocal == nil || g.builtinLocal.Provider == nil {
|
||||
return deterministicInterpretationResponse(request)
|
||||
}
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: interpretationPrompt(request), Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
|
||||
started := g.now()
|
||||
response, err := g.builtinLocal.Provider.Advise(ctx, invocation)
|
||||
if response.RequestID == "" {
|
||||
response.RequestID = request.RequestID
|
||||
}
|
||||
if response.Version == "" {
|
||||
response.Version = request.Version
|
||||
}
|
||||
if err != nil {
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
}
|
||||
g.observeBuiltin(ctx, g.builtinLocal, response, started)
|
||||
if err != nil || response.FinishReason != cognitive.FinishCompleted {
|
||||
return deterministicInterpretationResponse(request)
|
||||
}
|
||||
candidate, ok := parseInterpretationCandidate(response.AdvisoryText)
|
||||
if !ok {
|
||||
return deterministicInterpretationResponse(request)
|
||||
}
|
||||
return cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage}
|
||||
}
|
||||
|
||||
// deterministicInterpretationResponse keeps the semantic route alive when the
|
||||
// local model cannot produce strict JSON in time. It does not invent meaning:
|
||||
// it carries forward the already-recorded deterministic interpretation and
|
||||
// leaves the advisory answer to the built-in gateway path.
|
||||
func deterministicInterpretationResponse(request cognitive.GatewayInterpretationRequest) cognitive.GatewayInterpretationResponse {
|
||||
speechAct := request.DeterministicSpeechAct
|
||||
if !validSpeechAct(speechAct) {
|
||||
speechAct = "UNKNOWN"
|
||||
}
|
||||
applicability := request.DeterministicApplicability
|
||||
switch applicability {
|
||||
case cognitive.InterpretationApplicable, cognitive.InterpretationLowConfidence, cognitive.InterpretationMixedIntent, cognitive.InterpretationNeedsBroader, cognitive.InterpretationClarificationRequired:
|
||||
default:
|
||||
applicability = cognitive.InterpretationLowConfidence
|
||||
}
|
||||
confidence := request.DeterministicConfidence
|
||||
if confidence < 0 {
|
||||
confidence = 0
|
||||
}
|
||||
if confidence > 1000 {
|
||||
confidence = 1000
|
||||
}
|
||||
candidate := cognitive.GatewayInterpretationCandidate{
|
||||
PrimarySpeechAct: speechAct,
|
||||
SecondarySpeechAct: request.DeterministicSecondary,
|
||||
ConfidenceMillis: confidence,
|
||||
Applicability: applicability,
|
||||
RequestedOutcome: request.RequestedOutcome,
|
||||
NeedsCognition: true,
|
||||
Clarification: "NOT_REQUIRED",
|
||||
SelectionReason: "deterministic interpretation retained; built-in structured interpretation was unavailable",
|
||||
}
|
||||
if applicability == cognitive.InterpretationClarificationRequired {
|
||||
candidate.NeedsCognition = false
|
||||
candidate.Clarification = "REQUIRED"
|
||||
}
|
||||
return cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
|
||||
func selectionObjectiveForInterpretation(request cognitive.GatewayInterpretationRequest) capability.SelectionObjective {
|
||||
if request.RequestedOutcome != "" || request.DeterministicApplicability == cognitive.InterpretationNeedsBroader || request.DeterministicApplicability == cognitive.InterpretationMixedIntent || request.DeterministicApplicability == cognitive.InterpretationLowConfidence {
|
||||
return capability.SelectionQuality
|
||||
}
|
||||
if request.DeterministicConfidence >= 800 && request.DeterministicSpeechAct != "QUESTION" {
|
||||
return capability.SelectionSpeed
|
||||
}
|
||||
return capability.SelectionBalanced
|
||||
}
|
||||
|
||||
func interpretationPrompt(request cognitive.GatewayInterpretationRequest) string {
|
||||
projects := "none"
|
||||
if len(request.ProjectNames) > 0 {
|
||||
projects = strings.Join(request.ProjectNames, ", ")
|
||||
}
|
||||
return fmt.Sprintf(`Return only strict JSON with keys primary_speech_act, secondary_speech_act, confidence_millis, applicability, requested_outcome, needs_cognition, clarification, selection_reason, capability_hints.
|
||||
Allowed speech acts: DIRECTIVE, ASSERTION, DECISION, PREFERENCE, QUESTION, HYPOTHESIS, CORRECTION, REQUEST_TO_INVESTIGATE, CONFIRMATION, REJECTION, CANCELLATION, CONVERSATIONAL, UNKNOWN.
|
||||
Allowed applicability: APPLICABLE, LOW_CONFIDENCE, MIXED_INTENT, NEEDS_BROADER_INTERPRETATION, CLARIFICATION_REQUIRED.
|
||||
Allowed clarification: NOT_REQUIRED or REQUIRED.
|
||||
Capability hints may only contain known capability kinds like COGNITIVE_REASONING, MODIFY_CODE, RUN_TESTS, SEND_EMAIL, SCHEDULE_REMINDER, VERIFY_FACT, INSPECT_PROJECT, READ_DATABASE, QUERY_LOGS.
|
||||
Interpret the human utterance semantically. Do not claim action execution. Do not treat punctuation alone as decisive. If uncertain, set applicability accordingly.
|
||||
Message: %q
|
||||
Deterministic primary: %s
|
||||
Deterministic secondary: %s
|
||||
Deterministic confidence: %d
|
||||
Deterministic applicability: %s
|
||||
Deterministic requested outcome: %q
|
||||
Relevant project names: %s`, request.Message, request.DeterministicSpeechAct, request.DeterministicSecondary, request.DeterministicConfidence, request.DeterministicApplicability, request.RequestedOutcome, projects)
|
||||
}
|
||||
|
||||
func parseInterpretationCandidate(text string) (cognitive.GatewayInterpretationCandidate, bool) {
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start < 0 || end <= start {
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
var candidate cognitive.GatewayInterpretationCandidate
|
||||
if err := json.Unmarshal([]byte(text[start:end+1]), &candidate); err != nil {
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
if !validSpeechAct(candidate.PrimarySpeechAct) || candidate.ConfidenceMillis < 0 || candidate.ConfidenceMillis > 1000 {
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
if candidate.SecondarySpeechAct != "" && !validSpeechAct(candidate.SecondarySpeechAct) {
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
switch candidate.Applicability {
|
||||
case cognitive.InterpretationApplicable, cognitive.InterpretationLowConfidence, cognitive.InterpretationMixedIntent, cognitive.InterpretationNeedsBroader, cognitive.InterpretationClarificationRequired:
|
||||
default:
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
if candidate.Clarification != "NOT_REQUIRED" && candidate.Clarification != "REQUIRED" {
|
||||
return cognitive.GatewayInterpretationCandidate{}, false
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
func validSpeechAct(value string) bool {
|
||||
switch value {
|
||||
case "DIRECTIVE", "ASSERTION", "DECISION", "PREFERENCE", "QUESTION", "HYPOTHESIS", "CORRECTION", "REQUEST_TO_INVESTIGATE", "CONFIRMATION", "REJECTION", "CANCELLATION", "CONVERSATIONAL", "UNKNOWN":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeInterpretationResponse(w http.ResponseWriter, response cognitive.GatewayInterpretationResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestLiveInterpretationTrace(t *testing.T) {
|
||||
if os.Getenv("ASSISTANT_LIVE_SEMANTIC_TRACE") != "1" {
|
||||
t.Skip("set ASSISTANT_LIVE_SEMANTIC_TRACE=1 for live semantic/gateway/raw trace")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rawBase := envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1")
|
||||
rawModelID, err := firstRawModel(ctx, rawBase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(rawBase, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
discovered, probe := cpuAdapter.Discover(ctx)
|
||||
if probe.Health != capability.HealthAvailable || len(discovered) == 0 {
|
||||
t.Fatalf("cpu discovery unavailable: probe=%+v models=%+v", probe, discovered)
|
||||
}
|
||||
selected := capability.ResolvedCognitiveBinding{
|
||||
Binding: capability.CognitiveBinding{
|
||||
ID: "trace-binding",
|
||||
Revision: 1,
|
||||
CapabilityID: "creator-advisory",
|
||||
OwnerPrincipalID: "domovoy-creator-principal",
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
},
|
||||
Provider: capability.ProviderInstance{
|
||||
ID: "local-cpu-primary",
|
||||
Revision: 1,
|
||||
Kind: "openai-compatible-chat-v1",
|
||||
EndpointRef: "local-cpu-primary-v1",
|
||||
},
|
||||
Model: capability.ModelDescriptor{
|
||||
ID: capability.ModelDescriptorID("trace-model"),
|
||||
Revision: 1,
|
||||
ProviderModelID: discovered[0].ProviderModelID,
|
||||
},
|
||||
}
|
||||
gateway := cognitiveGateway{
|
||||
resolver: traceResolver{selected: selected},
|
||||
providers: map[capability.ProviderKind][]providerRegistration{
|
||||
"openai-compatible-chat-v1": {{
|
||||
ProviderInstanceID: "local-cpu-primary",
|
||||
Kind: "openai-compatible-chat-v1",
|
||||
EndpointRef: "local-cpu-primary-v1",
|
||||
Provider: cpuAdapter,
|
||||
}},
|
||||
},
|
||||
now: time.Now,
|
||||
allowCanary: true,
|
||||
}
|
||||
for _, message := range []string{"ты кто", "можешь расказать об утре"} {
|
||||
conversation := interaction.Conversation{ID: "trace-conversation", ActorID: "trace-actor", PrincipalID: "domovoy-creator-principal", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||||
utterance, _, _, err := interactiondomain.NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
semantic, err := interactiondomain.InterpretUtterance(utterance, []situation.ProjectDescriptor{{ID: "assistant", DisplayName: "Assistant"}}, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawResponse, err := rawCompletion(ctx, rawBase, rawModelID, message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cognitive.GatewayInterpretationRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "trace-request",
|
||||
ActorID: identity.ActorID("trace-actor"),
|
||||
PrincipalID: identity.PrincipalID("domovoy-creator-principal"),
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: message,
|
||||
Locale: "ru",
|
||||
MaxOutputBytes: 4096,
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
DeterministicSpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
DeterministicSecondary: string(semantic.Interpretation.SecondarySpeechAct),
|
||||
DeterministicConfidence: semantic.Interpretation.ConfidenceMillis,
|
||||
DeterministicApplicability: cognitive.InterpretationApplicability(semantic.Interpretation.Applicability),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
httpRequest := httptest.NewRequest(http.MethodPost, "/v1/interpretations", bytes.NewReader(payload))
|
||||
gateway.interpretationHandler(recorder, httpRequest.WithContext(ctx))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("message=%q gateway status=%d body=%s", message, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var gatewayResponse cognitive.GatewayInterpretationResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &gatewayResponse); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gatewayResponse.FinishReason != cognitive.FinishCompleted {
|
||||
t.Fatalf("message=%q gateway finish=%s body=%s", message, gatewayResponse.FinishReason, recorder.Body.String())
|
||||
}
|
||||
if rawResponse == "" {
|
||||
t.Fatalf("message=%q raw response is empty", message)
|
||||
}
|
||||
if message == "ты кто" {
|
||||
if semantic.Interpretation.SpeechAct != interaction.SpeechActQuestion || !semantic.NeedsCognition {
|
||||
t.Fatalf("semantic path still fails punctuation-free identity question: %+v", semantic.Interpretation)
|
||||
}
|
||||
if gatewayResponse.Candidate.PrimarySpeechAct != "QUESTION" {
|
||||
t.Fatalf("gateway did not preserve question semantics: %+v", gatewayResponse.Candidate)
|
||||
}
|
||||
}
|
||||
t.Logf("TRACE message=%q deterministic=%s/%s needs_cognition=%t raw=%q gateway_candidate=%+v", message, semantic.Interpretation.SpeechAct, semantic.Interpretation.Applicability, semantic.NeedsCognition, truncate(rawResponse, 180), gatewayResponse.Candidate)
|
||||
}
|
||||
}
|
||||
|
||||
type traceResolver struct {
|
||||
selected capability.ResolvedCognitiveBinding
|
||||
}
|
||||
|
||||
func (r traceResolver) Resolve(context.Context, capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
return r.selected, nil
|
||||
}
|
||||
|
||||
func (r traceResolver) Observe(context.Context, capability.CognitiveUsageObservation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r traceResolver) RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) {
|
||||
return capability.CognitiveDiscoveryRegistration{}, nil
|
||||
}
|
||||
|
||||
func firstRawModel(ctx context.Context, base string) (string, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/models", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload.Data) == 0 || payload.Data[0].ID == "" {
|
||||
return "", io.EOF
|
||||
}
|
||||
return payload.Data[0].ID, nil
|
||||
}
|
||||
|
||||
func rawCompletion(ctx context.Context, base, model, message string) (string, error) {
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 128,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": message},
|
||||
},
|
||||
"stream": false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
if len(value) <= max {
|
||||
return value
|
||||
}
|
||||
return value[:max] + "..."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func TestBuiltinInterpretationTimeoutRetainsDeterministicCandidate(t *testing.T) {
|
||||
request := cognitive.GatewayInterpretationRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-1",
|
||||
DeterministicSpeechAct: "QUESTION",
|
||||
DeterministicConfidence: 720,
|
||||
DeterministicApplicability: cognitive.InterpretationNeedsBroader,
|
||||
RequestedOutcome: "вывести список настроек",
|
||||
}
|
||||
response := (cognitiveGateway{}).interpretWithBuiltin(context.Background(), request, 4096)
|
||||
if response.FinishReason != cognitive.FinishCompleted {
|
||||
t.Fatalf("finish reason=%q", response.FinishReason)
|
||||
}
|
||||
if response.Candidate.PrimarySpeechAct != "QUESTION" || response.Candidate.ConfidenceMillis != 720 || response.Candidate.RequestedOutcome != request.RequestedOutcome {
|
||||
t.Fatalf("candidate did not retain deterministic meaning: %+v", response.Candidate)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -40,37 +43,207 @@ func main() {
|
||||
}
|
||||
providers := map[capability.ProviderKind][]providerRegistration{}
|
||||
discoveries := map[string]providerRegistration{}
|
||||
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), nil)
|
||||
var builtinLocal *providerRegistration
|
||||
externalRecovery := []providerRegistration{}
|
||||
cpuAPIKey, err := loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", Provider: cpuAdapter, Discovery: cpuAdapter}
|
||||
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapterWithAPIKey(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), cpuAPIKey, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cpuAuthMode := capability.AuthNone
|
||||
cpuCredentialRef := capability.CredentialRef("")
|
||||
if cpuAPIKey != "" {
|
||||
cpuAuthMode = capability.AuthKey
|
||||
cpuCredentialRef = capability.CredentialRef(envOr("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1"))
|
||||
}
|
||||
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", CredentialRef: cpuCredentialRef, AuthMode: cpuAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter}
|
||||
providers[cpu.Kind] = append(providers[cpu.Kind], cpu)
|
||||
discoveries["cpu"] = cpu
|
||||
mediumURL := strings.TrimSpace(os.Getenv("LOCAL_MEDIUM_PROVIDER_URL"))
|
||||
mediumAPIKey := ""
|
||||
if mediumURL != "" {
|
||||
mediumAPIKey, err = loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Fatal(adapterErr)
|
||||
}
|
||||
mediumAuthMode := capability.AuthNone
|
||||
mediumCredentialRef := capability.CredentialRef("")
|
||||
if mediumAPIKey != "" {
|
||||
mediumAuthMode = capability.AuthKey
|
||||
mediumCredentialRef = capability.CredentialRef(envOr("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"))
|
||||
}
|
||||
medium := providerRegistration{ProviderInstanceID: "local-medium-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-medium-primary-v1", CredentialRef: mediumCredentialRef, AuthMode: mediumAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: mediumAdapter, Discovery: mediumAdapter}
|
||||
providers[medium.Kind] = append(providers[medium.Kind], medium)
|
||||
discoveries["medium"] = medium
|
||||
}
|
||||
if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil {
|
||||
log.Fatal(lifeboatErr)
|
||||
} else if enabled {
|
||||
lifeboat.DataPolicy = capability.DataPolicyLocalOnly
|
||||
providers[lifeboat.Kind] = append(providers[lifeboat.Kind], lifeboat)
|
||||
discoveries["lifeboat"] = lifeboat
|
||||
builtinLocal = &lifeboat
|
||||
}
|
||||
gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", Provider: gpuAdapter, Discovery: gpuAdapter}
|
||||
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter}
|
||||
providers[gpu.Kind] = append(providers[gpu.Kind], gpu)
|
||||
discoveries["gpu"] = gpu
|
||||
externalRecovery = append(externalRecovery, gpu)
|
||||
if openAIProvider != nil {
|
||||
kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1"))
|
||||
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), Provider: openAIProvider})
|
||||
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: openAIProvider})
|
||||
}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096, admission: newHTTPAdmissionClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second})}
|
||||
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}
|
||||
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.FleetTierHeavy)))
|
||||
cpuFleetHeaders := map[string]string{}
|
||||
if cpuAPIKey != "" {
|
||||
cpuFleetHeaders["Authorization"] = "Bearer " + cpuAPIKey
|
||||
}
|
||||
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
|
||||
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
|
||||
cpuHealthProbe.Timeout = 10 * time.Second
|
||||
mediumEndpoint := ""
|
||||
mediumHeaders := map[string]string{}
|
||||
mediumHealthProbe := health.Probe{}
|
||||
if mediumURL != "" {
|
||||
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
|
||||
if mediumAPIKey != "" {
|
||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||
}
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||
mediumHealthProbe.Timeout = 10 * time.Second
|
||||
}
|
||||
readinessProbes := []health.Probe{
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
||||
cpuHealthProbe,
|
||||
}
|
||||
if mediumURL != "" {
|
||||
readinessProbes = append(readinessProbes, mediumHealthProbe)
|
||||
}
|
||||
readinessProbes = append(readinessProbes,
|
||||
health.HTTPProbe(readinessClient, lifeboatURL),
|
||||
health.OptionalHTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"),
|
||||
)
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes})
|
||||
mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, []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-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/") + "/api/tags"},
|
||||
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
|
||||
}))
|
||||
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
|
||||
mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler)
|
||||
mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler)
|
||||
mux.HandleFunc("POST /v1/interpretations", gateway.interpretationHandler)
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
|
||||
refreshProviderDiscoveries(resolver, discovery)
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
log.Printf("invalid %s=%q; using %s", name, value, fallback)
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
|
||||
for name := range service.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
registration, ok := service.providers[name]
|
||||
if !ok || registration.Discovery == nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
discovered, probe := registration.Discovery.Discover(ctx)
|
||||
cancel()
|
||||
snapshot := capability.CognitiveDiscoverySnapshot{
|
||||
ProviderInstanceID: registration.ProviderInstanceID,
|
||||
ProviderKind: registration.Kind,
|
||||
EndpointRef: registration.EndpointRef,
|
||||
CredentialRef: registration.CredentialRef,
|
||||
AuthMode: registration.AuthMode,
|
||||
BillingMode: registration.BillingMode,
|
||||
Enabled: true,
|
||||
OwnerPrincipalID: service.owner,
|
||||
CapabilityID: creatorAdvisoryCapabilityID,
|
||||
DataPolicy: registration.DataPolicy,
|
||||
LimitPolicyRef: "creator-advisory-local-v1",
|
||||
Health: probe.Health,
|
||||
FailureClass: capability.CognitiveFailureType(probe.Failure),
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
for _, model := range discovered {
|
||||
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{
|
||||
ProviderModelID: model.ProviderModelID,
|
||||
Fingerprint: model.Fingerprint,
|
||||
MaxContextBytes: model.MaxContextBytes,
|
||||
Modalities: model.Modalities,
|
||||
Capabilities: model.Capabilities,
|
||||
Installed: capability.OperationalYes,
|
||||
Loaded: capability.OperationalNotObserved,
|
||||
Invokable: capability.OperationalNotObserved,
|
||||
})
|
||||
}
|
||||
if _, err := resolver.RegisterDiscovery(context.Background(), snapshot); err != nil {
|
||||
log.Printf("startup cognitive discovery refresh failed for %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadProviderAPIKey(directEnvName, fileEnvName, refEnvName, defaultRef string) (string, error) {
|
||||
if _, set := os.LookupEnv(directEnvName); set {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s", directEnvName, fileEnvName)
|
||||
}
|
||||
fileName := strings.TrimSpace(os.Getenv(fileEnvName))
|
||||
if fileName == "" {
|
||||
return "", nil
|
||||
}
|
||||
ref := strings.TrimSpace(os.Getenv(refEnvName))
|
||||
if ref == "" {
|
||||
ref = defaultRef
|
||||
}
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("%s is required when %s is configured", refEnvName, fileEnvName)
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(envOr("SECRET_BACKEND_ROOT", "/run/secrets"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeProviderAPIKey, Consumer: secretcontracts.RuntimeConsumerCognitive, Path: fileName})
|
||||
}
|
||||
|
||||
func loadLocalCPUProviderAPIKey() (string, error) {
|
||||
return loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
}
|
||||
|
||||
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
@@ -173,3 +346,22 @@ func envOr(name, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func newLifeboatProvider(rawURL string) (providerRegistration, bool, error) {
|
||||
if rawURL == "" {
|
||||
return providerRegistration{}, false, nil
|
||||
}
|
||||
adapter, err := models.NewOpenAICompatibleLocalAdapter(rawURL, nil)
|
||||
if err != nil {
|
||||
return providerRegistration{}, false, err
|
||||
}
|
||||
return providerRegistration{
|
||||
ProviderInstanceID: "local-lifeboat",
|
||||
Kind: "openai-compatible-chat-v1",
|
||||
EndpointRef: "local-lifeboat-v1",
|
||||
AuthMode: capability.AuthNone,
|
||||
BillingMode: capability.BillingFree,
|
||||
Provider: adapter,
|
||||
Discovery: adapter,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
|
||||
if _, enabled, err := newLifeboatProvider(""); err != nil || enabled {
|
||||
t.Fatalf("empty lifeboat URL should disable provider: enabled=%v err=%v", enabled, err)
|
||||
}
|
||||
provider, enabled, err := newLifeboatProvider("http://lifeboat:8080/v1")
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("configured lifeboat should be enabled: enabled=%v err=%v", enabled, err)
|
||||
}
|
||||
if provider.ProviderInstanceID != "local-lifeboat" || provider.EndpointRef != "local-lifeboat-v1" || provider.Provider == nil {
|
||||
t.Fatalf("unexpected lifeboat registration: %+v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalCPUProviderAPIKeyUsesProtectedFileReference(t *testing.T) {
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "")
|
||||
_ = os.Unsetenv("LOCAL_CPU_PROVIDER_API_KEY")
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "local_cpu_provider_api_key")
|
||||
if err := os.WriteFile(path, []byte("resolved-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", path)
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
key, err := loadLocalCPUProviderAPIKey()
|
||||
if err != nil || key != "resolved-key" {
|
||||
t.Fatalf("unexpected key resolution result: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalCPUProviderAPIKeyRejectsDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "must-not-be-used")
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", "")
|
||||
if _, err := loadLocalCPUProviderAPIKey(); err == nil {
|
||||
t.Fatal("expected direct environment secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderAPIKeySupportsMediumCredentialRef(t *testing.T) {
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "")
|
||||
_ = os.Unsetenv("LOCAL_MEDIUM_PROVIDER_API_KEY")
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "medium_provider_api_key")
|
||||
if err := os.WriteFile(path, []byte("medium-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", path)
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
key, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
if err != nil || key != "medium-key" {
|
||||
t.Fatalf("unexpected medium key resolution result: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderAPIKeyRejectsMediumDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "must-not-be-used")
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "")
|
||||
if _, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"); err == nil {
|
||||
t.Fatal("expected direct medium environment secret to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -183,3 +183,29 @@ func (c creatorContinuityClient) Item(ctx context.Context, authorization string,
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func (c creatorContinuityClient) InternalItem(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/prospective-items/" + url.PathEscape(string(id))
|
||||
target.RawQuery = ""
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return continuity.ProspectiveItem{}, fmt.Errorf("%w: %v", errCreatorQueryUnavailable, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return continuity.ProspectiveItem{}, errCreatorSubjectMissing
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
var item continuity.ProspectiveItem
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&item); err != nil {
|
||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
|
||||
"git.cin.su/m/Assistant/modules/commit/ports"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
const prospectiveNotificationActor identity.ActorID = "domovoy-creator-actor"
|
||||
|
||||
type internalNotificationContinuity interface {
|
||||
InternalItem(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
||||
}
|
||||
|
||||
type internalNotificationHandler struct {
|
||||
core creatorSessionSource
|
||||
continuity internalNotificationContinuity
|
||||
authority creatorAuthoritySource
|
||||
repository creatorCommitRepository
|
||||
executor ports.Executor
|
||||
newID func() (ids.ID, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type internalProspectiveNotificationRequest struct {
|
||||
ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
|
||||
ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
|
||||
}
|
||||
|
||||
func registerInternalNotificationRoutes(mux *http.ServeMux, handler internalNotificationHandler, token string) {
|
||||
mux.HandleFunc("POST /v1/internal/continuity/notify-due", requireCommitToken(token, handler.notifyDue))
|
||||
}
|
||||
|
||||
func (h internalNotificationHandler) notifyDue(w http.ResponseWriter, r *http.Request) {
|
||||
var request internalProspectiveNotificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
|
||||
http.Error(w, "invalid prospective notification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
item, err := h.continuity.InternalItem(r.Context(), request.ProspectiveItemID)
|
||||
if errors.Is(err, errCreatorSubjectMissing) {
|
||||
http.Error(w, "prospective item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Continuity item unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.OwnerPrincipalID == "" || item.ScopeID == "" {
|
||||
http.Error(w, "prospective item not deliverable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
|
||||
if err != nil || scope.ID != item.ScopeID {
|
||||
http.Error(w, "Creator authority unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
identityBinding, err := h.core.Binding(r.Context(), prospectiveNotificationActor, item.OwnerPrincipalID)
|
||||
if err != nil {
|
||||
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, item.OwnerPrincipalID)
|
||||
if err != nil {
|
||||
http.Error(w, "notification capability unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
intentID, authorizedID, attemptID, err := internalActionIDs(h.newID)
|
||||
if err != nil {
|
||||
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
title := "Домовой: напоминание"
|
||||
message := "Пора: " + strings.TrimSpace(item.Summary)
|
||||
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
|
||||
digest, err := notificationexecutor.Digest(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
proposal := commitcontract.ActionProposal{
|
||||
IntentID: intentID, ActorID: prospectiveNotificationActor, CapabilityID: commitcontract.CapabilityProspectiveNotification,
|
||||
Target: work.TaskTarget{ScopeID: item.ScopeID, Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification,
|
||||
NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest},
|
||||
}
|
||||
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "Creator authorization denied", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
|
||||
http.Error(w, "commit authorized action", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
|
||||
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
||||
http.Error(w, "execute committed notification", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeCreatorActionResponse(w, status, action, attempt)
|
||||
}
|
||||
|
||||
func internalActionIDs(newID func() (ids.ID, error)) (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
|
||||
intent, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
authorized, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
attempt, err := newID()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
|
||||
}
|
||||
@@ -84,11 +84,22 @@ func main() {
|
||||
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "commit-runtime", Probes: []health.Probe{
|
||||
health.TCPProbe("commit-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
|
||||
health.TCPProbe("authority-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(coreURL, "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
|
||||
}})
|
||||
registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken)
|
||||
registerCreatorCommandRoutes(mux, creatorCommandHandler{
|
||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
|
||||
})
|
||||
registerInternalNotificationRoutes(mux, internalNotificationHandler{
|
||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||
executor: dispatcher, newID: ids.New, now: time.Now,
|
||||
}, executorToken)
|
||||
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request authorizationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type attentionRepository interface {
|
||||
AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
|
||||
CreateAttentionItem(context.Context, continuity.AttentionItem) (continuity.AttentionItem, error)
|
||||
}
|
||||
|
||||
func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorIdentityClient, repository attentionRepository) {
|
||||
mux.HandleFunc("GET /v1/creator/attention", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := creatorSessionForAttention(w, r, identityClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, err := parseListLimit(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid attention limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "attention unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(continuity.AttentionListResult{Items: items})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/today", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := creatorSessionForAttention(w, r, identityClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, 50)
|
||||
if err != nil {
|
||||
http.Error(w, "today unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
result := continuity.TodayResult{Items: make([]continuity.TodayItem, 0, len(items))}
|
||||
for _, item := range items {
|
||||
result.Items = append(result.Items, continuity.TodayItem{
|
||||
Title: item.Summary,
|
||||
WhenUnixMillis: item.DueAtUnixMillis,
|
||||
AttentionLevel: item.Level,
|
||||
AttentionState: item.State,
|
||||
HumanState: humanAttentionState(item.State),
|
||||
DeliveryState: humanDeliveryState(item),
|
||||
SourceDescription: humanAttentionSource(item.SourceKind),
|
||||
ResponseHint: humanResponseHint(item),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/attention", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := creatorSessionForAttention(w, r, identityClient)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request continuity.CreateAttentionRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid attention request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.OwnerPrincipalID == "" {
|
||||
request.OwnerPrincipalID = session.PrincipalID
|
||||
}
|
||||
if request.OwnerPrincipalID != session.PrincipalID || request.ScopeID == "" || request.SourceKind == "" || request.Level == "" || request.Summary == "" || request.CreatedAtUnixMillis < 1 || request.DueAtUnixMillis < 1 {
|
||||
http.Error(w, "invalid attention request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.SourceKind == continuity.AttentionSourceAgentDelegation && request.SourceExternalRef == "" {
|
||||
http.Error(w, "delegation attention requires source external ref", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
item := continuity.AttentionItem{
|
||||
ID: continuity.AttentionItemID(request.SourceExternalRef),
|
||||
OwnerPrincipalID: request.OwnerPrincipalID,
|
||||
ScopeID: request.ScopeID,
|
||||
SourceKind: request.SourceKind,
|
||||
SourceProspectiveID: request.SourceProspectiveID,
|
||||
SourceExternalRef: request.SourceExternalRef,
|
||||
Level: request.Level,
|
||||
State: continuity.AttentionPending,
|
||||
Summary: request.Summary,
|
||||
DueAtUnixMillis: request.DueAtUnixMillis,
|
||||
CreatedAtUnixMillis: request.CreatedAtUnixMillis,
|
||||
Revision: 1,
|
||||
}
|
||||
saved, err := repository.CreateAttentionItem(r.Context(), item)
|
||||
if err != nil {
|
||||
http.Error(w, "attention unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(saved)
|
||||
})
|
||||
}
|
||||
|
||||
func creatorSessionForAttention(w http.ResponseWriter, r *http.Request, identityClient creatorIdentityClient) (identity.CreatorSession, bool) {
|
||||
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if errors.Is(err, errCreatorSessionDenied) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func humanAttentionState(state continuity.AttentionState) string {
|
||||
switch state {
|
||||
case continuity.AttentionPending:
|
||||
return "ожидает внимания"
|
||||
case continuity.AttentionDelivering:
|
||||
return "доставляется"
|
||||
case continuity.AttentionDelivered:
|
||||
return "доставлено"
|
||||
case continuity.AttentionResolved:
|
||||
return "закрыто"
|
||||
case continuity.AttentionSuppressed:
|
||||
return "скрыто"
|
||||
case continuity.AttentionExpired:
|
||||
return "истекло"
|
||||
case continuity.AttentionFailed:
|
||||
return "не доставлено"
|
||||
default:
|
||||
return "состояние уточняется"
|
||||
}
|
||||
}
|
||||
|
||||
func humanResponseHint(item continuity.AttentionItem) string {
|
||||
if item.SourceKind == continuity.AttentionSourceAgentDelegation {
|
||||
if item.State == continuity.AttentionDelivered || item.State == continuity.AttentionPending || item.State == continuity.AttentionFailed {
|
||||
return "Можно ответить: «Продолжай» или «Нет, нужен другой вариант»."
|
||||
}
|
||||
if item.ResponseOutcome != "" {
|
||||
return "Ответ записан: " + string(item.ResponseOutcome)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if item.State == continuity.AttentionDelivered || item.State == continuity.AttentionPending || item.State == continuity.AttentionFailed {
|
||||
return "Можно ответить: «Готово», «Отложи на 10 минут» или «Отмени»."
|
||||
}
|
||||
if item.ResponseOutcome != "" {
|
||||
return "Ответ записан: " + string(item.ResponseOutcome)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func humanDeliveryState(item continuity.AttentionItem) string {
|
||||
if item.State == continuity.AttentionDelivered {
|
||||
return "уведомление отправлено"
|
||||
}
|
||||
if item.State == continuity.AttentionDelivering {
|
||||
return "уведомление отправляется"
|
||||
}
|
||||
return "уведомление ещё не отправлено"
|
||||
}
|
||||
|
||||
func humanAttentionSource(kind continuity.AttentionSourceKind) string {
|
||||
if kind == continuity.AttentionSourceProspectiveReminder {
|
||||
return "напоминание"
|
||||
}
|
||||
if kind == continuity.AttentionSourceAgentDelegation {
|
||||
return "задача coding-agent"
|
||||
}
|
||||
return "событие Домового"
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type dueReminderRepository interface {
|
||||
DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error)
|
||||
ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error)
|
||||
PendingAttention(context.Context, int) ([]continuity.AttentionItem, error)
|
||||
MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error)
|
||||
MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error)
|
||||
MarkAttentionFailed(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string) (continuity.AttentionItem, error)
|
||||
Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
||||
}
|
||||
|
||||
type dueNotificationClient struct {
|
||||
baseURL *url.URL
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type dueNotifier interface {
|
||||
NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error)
|
||||
}
|
||||
|
||||
type dueNotificationRequest struct {
|
||||
ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
|
||||
ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
|
||||
}
|
||||
|
||||
type dueNotificationResponse struct {
|
||||
AuthorizedActionID commitcontract.AuthorizedActionID `json:"AuthorizedActionID"`
|
||||
ActionIntentID commitcontract.ActionIntentID `json:"ActionIntentID"`
|
||||
ActionAttemptID commitcontract.ActionAttemptID `json:"ActionAttemptID"`
|
||||
State commitcontract.ActionState `json:"State"`
|
||||
}
|
||||
|
||||
func newDueNotificationClient(rawURL, token string, client *http.Client) (dueNotificationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" || token == "" {
|
||||
return dueNotificationClient{}, fmt.Errorf("invalid Commit due notification configuration")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return dueNotificationClient{baseURL: base, token: token, client: client}, nil
|
||||
}
|
||||
|
||||
func (c dueNotificationClient) NotifyDue(ctx context.Context, item continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/internal/continuity/notify-due"
|
||||
payload, err := json.Marshal(dueNotificationRequest{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision})
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted {
|
||||
return dueNotificationResponse{}, fmt.Errorf("commit due notification status %d", response.StatusCode)
|
||||
}
|
||||
var result dueNotificationResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return dueNotificationResponse{}, err
|
||||
}
|
||||
if result.ActionIntentID == "" || result.AuthorizedActionID == "" || result.ActionAttemptID == "" {
|
||||
return dueNotificationResponse{}, fmt.Errorf("invalid commit due notification response")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type dueReminderWorker struct {
|
||||
repository dueReminderRepository
|
||||
commit dueNotifier
|
||||
now func() time.Time
|
||||
newID func() (ids.ID, error)
|
||||
interval time.Duration
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) Start(ctx context.Context) {
|
||||
if w.interval <= 0 {
|
||||
w.interval = 15 * time.Second
|
||||
}
|
||||
if w.limit <= 0 {
|
||||
w.limit = 10
|
||||
}
|
||||
go func() {
|
||||
w.process(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.process(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) process(ctx context.Context) {
|
||||
now := w.now()
|
||||
if err := w.claimDue(ctx, now); err != nil {
|
||||
log.Printf("due reminder claim skipped: %v", err)
|
||||
}
|
||||
if err := w.deliverPending(ctx, now); err != nil {
|
||||
log.Printf("due reminder delivery skipped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) claimDue(ctx context.Context, now time.Time) error {
|
||||
items, err := w.repository.DueArmed(ctx, now.UnixMilli(), w.limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
id, err := w.newID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err = w.repository.ClaimDueReminder(ctx, item.ID, item.Revision, continuity.AttentionItemID(id), now.UnixMilli()); err != nil {
|
||||
log.Printf("due reminder %s not claimed: %v", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w dueReminderWorker) deliverPending(ctx context.Context, now time.Time) error {
|
||||
items, err := w.repository.PendingAttention(ctx, w.limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, attention := range items {
|
||||
if attention.SourceKind != continuity.AttentionSourceProspectiveReminder || attention.SourceProspectiveID == "" {
|
||||
continue
|
||||
}
|
||||
delivering, err := w.repository.MarkAttentionDelivering(ctx, attention.ID, attention.Revision)
|
||||
if err != nil {
|
||||
log.Printf("attention %s not marked delivering: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
prospective, err := w.repository.Load(ctx, delivering.SourceProspectiveID)
|
||||
if err != nil {
|
||||
log.Printf("attention %s source unavailable: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
result, err := w.commit.NotifyDue(ctx, prospective)
|
||||
if err != nil {
|
||||
log.Printf("attention %s notification unavailable: %v", attention.ID, err)
|
||||
continue
|
||||
}
|
||||
if result.State != commitcontract.ActionSucceeded {
|
||||
_, err = w.repository.MarkAttentionFailed(ctx, delivering.ID, delivering.Revision,
|
||||
continuity.CommittedActionIntentID(result.ActionIntentID),
|
||||
continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
|
||||
continuity.CommittedActionAttemptID(result.ActionAttemptID),
|
||||
string(result.State))
|
||||
if err != nil {
|
||||
log.Printf("attention %s delivery failure outcome not persisted: %v", attention.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err = w.repository.MarkAttentionDelivered(ctx, delivering.ID, delivering.Revision,
|
||||
continuity.CommittedActionIntentID(result.ActionIntentID),
|
||||
continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
|
||||
continuity.CommittedActionAttemptID(result.ActionAttemptID),
|
||||
string(result.State), now.UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("attention %s delivery outcome not persisted: %v", attention.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type memoryDueRepo struct {
|
||||
item continuity.ProspectiveItem
|
||||
attention continuity.AttentionItem
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) {
|
||||
if r.item.State == continuity.ProspectiveArmed {
|
||||
return []continuity.ProspectiveItem{r.item}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) ClaimDueReminder(_ context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, now int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
|
||||
if r.item.ID != id || r.item.Revision != revision || r.item.State != continuity.ProspectiveArmed {
|
||||
return continuity.ProspectiveItem{}, continuity.AttentionItem{}, nil
|
||||
}
|
||||
r.item.State = continuity.ProspectiveDelivered
|
||||
r.item.Revision += 3
|
||||
r.attention = continuity.AttentionItem{ID: attentionID, OwnerPrincipalID: r.item.OwnerPrincipalID, ScopeID: r.item.ScopeID, SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: r.item.ID, Level: continuity.AttentionDue, State: continuity.AttentionPending, Summary: r.item.Summary, DueAtUnixMillis: r.item.TriggerAtUnixMillis, CreatedAtUnixMillis: now, Revision: 1}
|
||||
return r.item, r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) {
|
||||
if r.attention.State == continuity.AttentionPending {
|
||||
return []continuity.AttentionItem{r.attention}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionDelivering(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionDelivering
|
||||
r.attention.Revision++
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionDelivered(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAt int64) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionDelivered
|
||||
r.attention.Revision++
|
||||
r.attention.ActionIntentID = intentID
|
||||
r.attention.AuthorizedActionID = authorizedID
|
||||
r.attention.ActionAttemptID = attemptID
|
||||
r.attention.DeliveryOutcome = outcome
|
||||
r.attention.DeliveredAtUnixMillis = deliveredAt
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) MarkAttentionFailed(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||
if r.attention.ID == id && r.attention.Revision == revision {
|
||||
r.attention.State = continuity.AttentionFailed
|
||||
r.attention.Revision++
|
||||
r.attention.ActionIntentID = intentID
|
||||
r.attention.AuthorizedActionID = authorizedID
|
||||
r.attention.ActionAttemptID = attemptID
|
||||
r.attention.DeliveryOutcome = outcome
|
||||
}
|
||||
return r.attention, nil
|
||||
}
|
||||
|
||||
func (r *memoryDueRepo) Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
|
||||
return r.item, nil
|
||||
}
|
||||
|
||||
type fakeDueNotifier struct{ calls int }
|
||||
|
||||
func (n *fakeDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
n.calls++
|
||||
return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionSucceeded}, nil
|
||||
}
|
||||
|
||||
type unknownDueNotifier struct{ calls int }
|
||||
|
||||
func (n *unknownDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||
n.calls++
|
||||
return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionUnknownOutcome}, nil
|
||||
}
|
||||
|
||||
func TestDueReminderProducesAttentionAndNotificationOnce(t *testing.T) {
|
||||
repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}}
|
||||
notifier := &fakeDueNotifier{}
|
||||
now := time.UnixMilli(20)
|
||||
worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10}
|
||||
worker.process(context.Background())
|
||||
worker.process(context.Background())
|
||||
if repo.item.State != continuity.ProspectiveDelivered || repo.attention.State != continuity.AttentionDelivered {
|
||||
t.Fatalf("unexpected states: prospective=%s attention=%s", repo.item.State, repo.attention.State)
|
||||
}
|
||||
if notifier.calls != 1 {
|
||||
t.Fatalf("expected one notification call, got %d", notifier.calls)
|
||||
}
|
||||
if repo.attention.ActionIntentID == "" || repo.attention.DeliveredAtUnixMillis == 0 {
|
||||
t.Fatal("delivery evidence was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueReminderUnknownOutcomeDoesNotMarkAttentionDelivered(t *testing.T) {
|
||||
repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}}
|
||||
notifier := &unknownDueNotifier{}
|
||||
now := time.UnixMilli(20)
|
||||
worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10}
|
||||
worker.process(context.Background())
|
||||
if repo.attention.State != continuity.AttentionFailed {
|
||||
t.Fatalf("unknown outcome must not be delivered, got %s", repo.attention.State)
|
||||
}
|
||||
if repo.attention.DeliveryOutcome != string(commitcontract.ActionUnknownOutcome) {
|
||||
t.Fatalf("missing unknown outcome evidence: %#v", repo.attention)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type responseRepository interface {
|
||||
ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error)
|
||||
ApplyHumanResponse(context.Context, continuity.HumanResponse) error
|
||||
}
|
||||
|
||||
func registerCreatorResponseRoute(mux *http.ServeMux, identityClient creatorIdentityClient, repository responseRepository, now func() time.Time) {
|
||||
mux.HandleFunc("POST /v1/creator/responses", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if errors.Is(err, errCreatorSessionDenied) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var request continuity.HumanResponseRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
responseID, err := ids.New()
|
||||
if err != nil {
|
||||
http.Error(w, "human response unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
candidates, err := repository.ResponseCandidates(r.Context(), session.PrincipalID, 20)
|
||||
if err != nil {
|
||||
http.Error(w, "response candidates unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
result, err := continuitydomain.InterpretHumanResponse(request, session.PrincipalID, authority.ScopeID("domovoy-global-scope"), continuity.HumanResponseID(responseID), candidates, now())
|
||||
if err != nil {
|
||||
http.Error(w, "invalid human response", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if result.Handled {
|
||||
if err := repository.ApplyHumanResponse(r.Context(), result.Response); err != nil {
|
||||
http.Error(w, "apply human response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
)
|
||||
|
||||
func TestCreatorResponseEndpointAppliesCompleteThroughContinuitySemantics(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer ok" {
|
||||
t.Fatal("unexpected identity validation")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{{
|
||||
Attention: continuity.AttentionItem{ID: "attention-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "проверить базу", Revision: 1},
|
||||
Prospective: continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: "principal-1", ScopeID: "scope-1", State: continuity.ProspectiveDelivered, Summary: "проверить базу", Revision: 1},
|
||||
}}}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.applied.Operation != continuity.ResponseComplete || repository.applied.Outcome != continuity.ResponseOutcomeUserReportedComplete {
|
||||
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorResponseEndpointRecordsAmbiguousWithoutSilentTargetMutation(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{
|
||||
{Attention: continuity.AttentionItem{ID: "attention-1", SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "первое"}},
|
||||
{Attention: continuity.AttentionItem{ID: "attention-2", SourceProspectiveID: "prospective-2", State: continuity.AttentionDelivered, Summary: "второе"}},
|
||||
}}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Готово","RequestedAtUnixMillis":100000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.applied.ResolutionState != continuity.ResponseAmbiguous || repository.applied.TargetAttentionID != "" {
|
||||
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorResponseEndpointContinuesDelegationAttentionViaNaturalMessage(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeResponseRepository{candidates: []continuitydomain.ResponseCandidate{
|
||||
{Attention: continuity.AttentionItem{
|
||||
ID: "work:delegation:delegation-1",
|
||||
OwnerPrincipalID: "principal-1",
|
||||
ScopeID: "scope-1",
|
||||
SourceKind: continuity.AttentionSourceAgentDelegation,
|
||||
SourceExternalRef: "work:delegation:delegation-1",
|
||||
State: continuity.AttentionPending,
|
||||
Summary: "Нужен твой выбор по задаче coding-agent: docs/PROJECT_STATUS.md",
|
||||
Revision: 1,
|
||||
}},
|
||||
{Attention: continuity.AttentionItem{ID: "attention-1", SourceProspectiveID: "prospective-1", State: continuity.AttentionDelivered, Summary: "старое напоминание"}},
|
||||
}}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, func() time.Time { return time.Unix(100, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/responses", strings.NewReader(`{"SourceUtteranceID":"utterance-1","SourceIntentID":"intent-1","Message":"Продолжай задачу coding-agent по docs/PROJECT_STATUS.md","RequestedAtUnixMillis":100000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.applied.Operation != continuity.ResponseContinue || repository.applied.TargetExternalRef != "work:delegation:delegation-1" || repository.applied.Outcome != continuity.ResponseOutcomeContinued {
|
||||
t.Fatalf("status=%d applied=%#v body=%s", recorder.Code, repository.applied, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeResponseRepository struct {
|
||||
candidates []continuitydomain.ResponseCandidate
|
||||
applied continuity.HumanResponse
|
||||
}
|
||||
|
||||
func (f *fakeResponseRepository) ResponseCandidates(context.Context, identity.PrincipalID, int) ([]continuitydomain.ResponseCandidate, error) {
|
||||
return f.candidates, nil
|
||||
}
|
||||
|
||||
func (f *fakeResponseRepository) ApplyHumanResponse(_ context.Context, response continuity.HumanResponse) error {
|
||||
f.applied = response
|
||||
return nil
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
@@ -30,11 +32,25 @@ func main() {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "continuity-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "continuity-runtime", Probes: []health.Probe{
|
||||
health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), "/")+"/healthz"),
|
||||
}})
|
||||
executorToken := os.Getenv("COMMIT_EXECUTOR_TOKEN")
|
||||
if executorToken == "" {
|
||||
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
|
||||
}
|
||||
commitClient, err := newDueNotificationClient(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), executorToken, &http.Client{Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
workerContext, cancelWorker := context.WithCancel(context.Background())
|
||||
defer cancelWorker()
|
||||
dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext)
|
||||
registerInternalExecutorRoutes(mux, repository, executorToken)
|
||||
registerCreatorResponseRoute(mux, identityClient, repository, time.Now)
|
||||
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
||||
var item continuity.ProspectiveItem
|
||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||
@@ -47,6 +63,7 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
registerCreatorReminderRoute(mux, identityClient, repository)
|
||||
mux.HandleFunc("GET /v1/prospective-items/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := repository.Load(r.Context(), continuity.ProspectiveItemID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
@@ -103,6 +120,7 @@ func main() {
|
||||
Items []continuity.ProspectiveItem
|
||||
}{Items: items})
|
||||
})
|
||||
registerCreatorAttentionRoutes(mux, identityClient, repository)
|
||||
log.Printf("continuity-runtime listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
continuitydomain "git.cin.su/m/Assistant/modules/continuity/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type reminderRepository interface {
|
||||
Save(context.Context, continuity.ProspectiveItem) error
|
||||
}
|
||||
|
||||
func registerCreatorReminderRoute(mux *http.ServeMux, identityClient creatorIdentityClient, repository reminderRepository) {
|
||||
mux.HandleFunc("POST /v1/creator/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if errors.Is(err, errCreatorSessionDenied) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var request continuity.ScheduleReminderRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid reminder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
request.OwnerPrincipalID = session.PrincipalID
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
http.Error(w, "reminder unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
item, err := continuitydomain.BuildReminder(request, continuity.ProspectiveItemID(id))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid reminder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := repository.Save(r.Context(), item); err != nil {
|
||||
http.Error(w, "save reminder", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(continuity.ScheduleReminderResult{Item: item})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
func TestCreatorReminderEndpointBuildsOwnedProspectiveItem(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer ok" {
|
||||
t.Fatal("unexpected identity validation")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
}))
|
||||
defer core.Close()
|
||||
identityClient, err := newCreatorIdentityClient(core.URL, core.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &fakeReminderRepository{}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorReminderRoute(mux, identityClient, repository)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/reminders", strings.NewReader(`{"ScopeID":"scope-1","SourceIntentID":"intent-1","SourceUtteranceID":"utterance-1","Summary":"позвонить врачу","TriggerAtUnixMillis":2000,"RequestedAtUnixMillis":1000}`))
|
||||
request.Header.Set("Authorization", "Bearer ok")
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated || repository.saved.OwnerPrincipalID != "principal-1" || repository.saved.Summary != "позвонить врачу" || repository.saved.State != continuity.ProspectiveArmed {
|
||||
t.Fatalf("status=%d saved=%#v body=%s", recorder.Code, repository.saved, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeReminderRepository struct {
|
||||
saved continuity.ProspectiveItem
|
||||
}
|
||||
|
||||
func (f *fakeReminderRepository) Save(_ context.Context, item continuity.ProspectiveItem) error {
|
||||
f.saved = item
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type continuityReminderClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type continuityResponseClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type continuityAttentionClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newContinuityReminderClient(rawURL string, client *http.Client) (continuityReminderClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return continuityReminderClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return continuityReminderClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func newContinuityResponseClient(rawURL string, client *http.Client) (continuityResponseClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return continuityResponseClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return continuityResponseClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func newContinuityAttentionClient(rawURL string, client *http.Client) (continuityAttentionClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return continuityAttentionClient{}, fmt.Errorf("invalid Continuity Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return continuityAttentionClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func (c continuityReminderClient) Schedule(ctx context.Context, authorization string, request continuity.ScheduleReminderRequest) (continuity.ScheduleReminderResult, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/creator/reminders"
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return continuity.ScheduleReminderResult{}, err
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return continuity.ScheduleReminderResult{}, err
|
||||
}
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
upstream.Header.Set("Authorization", authorization)
|
||||
response, err := c.client.Do(upstream)
|
||||
if err != nil {
|
||||
return continuity.ScheduleReminderResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
return continuity.ScheduleReminderResult{}, fmt.Errorf("continuity reminder status %d", response.StatusCode)
|
||||
}
|
||||
var result continuity.ScheduleReminderResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return continuity.ScheduleReminderResult{}, err
|
||||
}
|
||||
if result.Item.ID == "" || result.Item.State != continuity.ProspectiveArmed {
|
||||
return continuity.ScheduleReminderResult{}, fmt.Errorf("invalid reminder result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c continuityResponseClient) Respond(ctx context.Context, authorization string, request continuity.HumanResponseRequest) (continuity.HumanResponseResult, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/creator/responses"
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
upstream.Header.Set("Authorization", authorization)
|
||||
response, err := c.client.Do(upstream)
|
||||
if err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
return continuity.HumanResponseResult{}, fmt.Errorf("continuity response status %d", response.StatusCode)
|
||||
}
|
||||
var result continuity.HumanResponseResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return continuity.HumanResponseResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c continuityAttentionClient) Create(ctx context.Context, authorization string, request continuity.CreateAttentionRequest) (continuity.AttentionItem, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/creator/attention"
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return continuity.AttentionItem{}, err
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return continuity.AttentionItem{}, err
|
||||
}
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
upstream.Header.Set("Authorization", authorization)
|
||||
response, err := c.client.Do(upstream)
|
||||
if err != nil {
|
||||
return continuity.AttentionItem{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
return continuity.AttentionItem{}, fmt.Errorf("continuity attention status %d", response.StatusCode)
|
||||
}
|
||||
var result continuity.AttentionItem
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return continuity.AttentionItem{}, err
|
||||
}
|
||||
if result.ID == "" || result.SourceKind == "" {
|
||||
return continuity.AttentionItem{}, fmt.Errorf("invalid attention result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveChatCapability(ctx context.Context, repository *postgres.CapabilityRepository, kind capability.CapabilityKind, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (capability.CapabilityResolution, error) {
|
||||
requirementID, err := ids.New()
|
||||
if err != nil {
|
||||
return capability.CapabilityResolution{}, err
|
||||
}
|
||||
requirement := capability.CapabilityResolutionRequirement{
|
||||
RequirementID: capability.RequirementRef(requirementID),
|
||||
CapabilityKind: kind,
|
||||
OwnerPrincipalID: session.PrincipalID,
|
||||
ActorID: session.ActorID,
|
||||
OriginalIntentID: capability.OriginalIntentRef(intent.ID),
|
||||
SourceUtteranceID: capability.SourceUtteranceRef(utterance.ID),
|
||||
RequiredConstraints: []capability.DataConstraint{capability.DataConstraintLocalOnly},
|
||||
AllowCanary: allowCanaryForChatCapability(kind),
|
||||
AuthorityContextRef: capability.AuthorityPolicyCreatorDirectiveV1,
|
||||
ObservedAtUnixMillis: now.UnixMilli(),
|
||||
}
|
||||
candidates, err := repository.LoadCapabilityImplementations(ctx, kind, session.PrincipalID)
|
||||
if err != nil {
|
||||
return capability.CapabilityResolution{}, err
|
||||
}
|
||||
resolution, err := capabilitydomain.ResolveCapability(requirement, candidates)
|
||||
if errors.Is(err, capabilitydomain.ErrNoCapabilityImplementation) && resolution.Gap != nil {
|
||||
if saveErr := repository.SaveCapabilityGap(ctx, *resolution.Gap); saveErr != nil {
|
||||
return capability.CapabilityResolution{}, saveErr
|
||||
}
|
||||
return resolution, err
|
||||
}
|
||||
return resolution, err
|
||||
}
|
||||
|
||||
func allowCanaryForChatCapability(kind capability.CapabilityKind) bool {
|
||||
switch kind {
|
||||
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type createdDelegation struct {
|
||||
Delegation work.Delegation
|
||||
Run work.AgentRun
|
||||
}
|
||||
|
||||
func maybeCreateAgentDelegation(ctx context.Context, workRepository *postgres.WorkRepository, capabilityRepository *postgres.CapabilityRepository, session identity.CreatorSession, conversation interaction.Conversation, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, planning work.PlanningResult, now time.Time) (*createdDelegation, capability.CapabilityResolution, error) {
|
||||
requirement, ok := primarySoftwareRequirement(planning.Candidate.Requirements)
|
||||
if !ok {
|
||||
return nil, capability.CapabilityResolution{}, nil
|
||||
}
|
||||
resolution, err := resolveChatCapability(ctx, capabilityRepository, requirement.CapabilityKind, session, intent, utterance, now)
|
||||
if err != nil {
|
||||
return nil, resolution, err
|
||||
}
|
||||
if len(resolution.Eligible) == 0 || resolution.Eligible[0].Implementation.Kind != capability.ImplementationAgent {
|
||||
return nil, resolution, capabilitydomain.ErrNoCapabilityImplementation
|
||||
}
|
||||
started, err := workdomain.StartDelegation(workdomain.DelegationRequest{
|
||||
Goal: planning.Goal,
|
||||
Plan: planning.Plan,
|
||||
Candidate: planning.Candidate,
|
||||
Requirement: requirement,
|
||||
Implementation: resolution.Eligible[0].Implementation,
|
||||
ConversationID: work.ConversationRef(conversation.ID),
|
||||
OriginalIntentText: intent.Text,
|
||||
ContextSummary: planning.Candidate.Summary,
|
||||
ContextRefs: []string{"interaction:intent:" + string(intent.ID), "interaction:utterance:" + string(utterance.ID), "work:plan:" + string(planning.Plan.ID)},
|
||||
VerificationSummary: "verify through changed files, relevant tests, and explicit run evidence before work completion",
|
||||
DerivedInstruction: buildSoftwareAgentInstruction(planning, intent, utterance),
|
||||
StartedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, capability.CapabilityResolution{}, err
|
||||
}
|
||||
if err := workRepository.SaveDelegationBundle(ctx, started.Delegation, started.Run); err != nil {
|
||||
return nil, capability.CapabilityResolution{}, err
|
||||
}
|
||||
return &createdDelegation{Delegation: started.Delegation, Run: started.Run}, resolution, nil
|
||||
}
|
||||
|
||||
func primarySoftwareRequirement(values []work.CapabilityRequirement) (work.CapabilityRequirement, bool) {
|
||||
for _, requirement := range values {
|
||||
switch requirement.CapabilityKind {
|
||||
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
||||
return requirement, true
|
||||
}
|
||||
}
|
||||
return work.CapabilityRequirement{}, false
|
||||
}
|
||||
|
||||
func buildSoftwareAgentInstruction(planning work.PlanningResult, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance) string {
|
||||
steps := make([]string, 0, len(planning.Candidate.Steps))
|
||||
for _, step := range planning.Candidate.Steps {
|
||||
if step.Description != "" {
|
||||
steps = append(steps, step.Description)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{
|
||||
"Human asked: " + strings.TrimSpace(intent.Text),
|
||||
"Original utterance: " + strings.TrimSpace(utterance.Content),
|
||||
"Desired outcome: " + planning.Goal.DesiredOutcome,
|
||||
"Plan summary: " + planning.Candidate.Summary,
|
||||
"Verification: run relevant tests and keep explicit evidence before claiming completion.",
|
||||
"Steps: " + strings.Join(steps, " | "),
|
||||
}, "\n"))
|
||||
}
|
||||
|
||||
func observeCapabilityUsage(ctx context.Context, repository *postgres.CapabilityRepository, selected capability.ResolvedCapabilityImplementation, requirement capability.CapabilityResolutionRequirement, outcome capability.UsageOutcomeClass, outcomeRef string, started time.Time, now time.Time) (capability.CapabilityUsageObservationID, error) {
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
latency := now.Sub(started).Milliseconds()
|
||||
observation := capability.CapabilityUsageObservation{
|
||||
ID: capability.CapabilityUsageObservationID(id),
|
||||
ImplementationID: selected.Implementation.ID,
|
||||
ImplementationRevision: selected.Implementation.Revision,
|
||||
CapabilityID: selected.Implementation.CapabilityID,
|
||||
CapabilityKind: selected.Implementation.CapabilityKind,
|
||||
OwnerPrincipalID: selected.Implementation.OwnerPrincipalID,
|
||||
OriginalIntentID: requirement.OriginalIntentID,
|
||||
SourceUtteranceID: requirement.SourceUtteranceID,
|
||||
Outcome: outcome,
|
||||
OutcomeRef: outcomeRef,
|
||||
LatencyMillis: &latency,
|
||||
ObservedAtUnixMillis: now.UnixMilli(),
|
||||
}
|
||||
if err := repository.SaveCapabilityUsageObservation(ctx, observation); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return observation.ID, nil
|
||||
}
|
||||
|
||||
func buildReminderRequest(message string, session identity.CreatorSession, intent interaction.OriginalHumanIntent, utterance interaction.HumanUtterance, now time.Time) (continuity.ScheduleReminderRequest, error) {
|
||||
trigger, err := parseReminderTime(message, now)
|
||||
if err != nil {
|
||||
return continuity.ScheduleReminderRequest{}, err
|
||||
}
|
||||
summary := reminderSummary(message)
|
||||
if summary == "" {
|
||||
summary = strings.TrimSpace(message)
|
||||
}
|
||||
return continuity.ScheduleReminderRequest{
|
||||
OwnerPrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
SourceIntentID: string(intent.ID),
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
Summary: summary,
|
||||
TriggerAtUnixMillis: trigger.UnixMilli(),
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseReminderTime(message string, now time.Time) (time.Time, error) {
|
||||
lower := strings.ToLower(message)
|
||||
relative := regexp.MustCompile(`через\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`)
|
||||
if match := relative.FindStringSubmatch(lower); len(match) > 0 {
|
||||
value, err := strconv.Atoi(match[1])
|
||||
if err != nil || value < 1 {
|
||||
return time.Time{}, fmt.Errorf("invalid relative reminder time")
|
||||
}
|
||||
unit := match[2]
|
||||
if strings.HasPrefix(unit, "час") {
|
||||
if value > 72 {
|
||||
return time.Time{}, fmt.Errorf("relative reminder time is too far")
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Hour), nil
|
||||
}
|
||||
if value > 24*60 {
|
||||
return time.Time{}, fmt.Errorf("relative reminder time is too far")
|
||||
}
|
||||
return now.Add(time.Duration(value) * time.Minute), nil
|
||||
}
|
||||
day := now
|
||||
if strings.Contains(lower, "послезавтра") {
|
||||
day = now.AddDate(0, 0, 2)
|
||||
} else if strings.Contains(lower, "завтра") {
|
||||
day = now.AddDate(0, 0, 1)
|
||||
}
|
||||
hour, minute := 9, 0
|
||||
re := regexp.MustCompile(`(?:в|на)\s+([0-2]?\d)(?::([0-5]\d))?`)
|
||||
if match := re.FindStringSubmatch(lower); len(match) > 0 {
|
||||
parsedHour, err := strconv.Atoi(match[1])
|
||||
if err != nil || parsedHour > 23 {
|
||||
return time.Time{}, fmt.Errorf("invalid reminder hour")
|
||||
}
|
||||
hour = parsedHour
|
||||
if match[2] != "" {
|
||||
parsedMinute, err := strconv.Atoi(match[2])
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid reminder minute")
|
||||
}
|
||||
minute = parsedMinute
|
||||
}
|
||||
}
|
||||
trigger := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !trigger.After(now) {
|
||||
trigger = trigger.AddDate(0, 0, 1)
|
||||
}
|
||||
return trigger, nil
|
||||
}
|
||||
|
||||
func reminderSummary(message string) string {
|
||||
clean := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(clean)
|
||||
for _, prefix := range []string{"напомни мне", "напомни"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
clean = strings.TrimSpace(clean[len(prefix):])
|
||||
lower = strings.ToLower(clean)
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, word := range []string{"послезавтра", "завтра", "сегодня"} {
|
||||
clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, ""))
|
||||
}
|
||||
relative := regexp.MustCompile(`(?i)через\s+\d{1,3}\s*(минуту|минуты|минут|час|часа|часов)`)
|
||||
clean = strings.TrimSpace(relative.ReplaceAllString(clean, ""))
|
||||
re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`)
|
||||
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
|
||||
clean = strings.Trim(clean, " .,!?:;")
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestBuildReminderRequestParsesTomorrowAtTen(t *testing.T) {
|
||||
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60))
|
||||
request, err := buildReminderRequest("Напомни завтра в 10 позвонить врачу.", identity.CreatorSession{PrincipalID: "principal-1"}, interaction.OriginalHumanIntent{ID: "intent-1"}, interaction.HumanUtterance{ID: "utterance-1"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trigger := time.UnixMilli(request.TriggerAtUnixMillis).In(now.Location())
|
||||
if trigger.Year() != 2026 || trigger.Month() != 8 || trigger.Day() != 18 || trigger.Hour() != 10 || trigger.Minute() != 0 {
|
||||
t.Fatalf("unexpected trigger: %s", trigger)
|
||||
}
|
||||
if request.Summary != "позвонить врачу" || request.SourceIntentID != "intent-1" || request.SourceUtteranceID != "utterance-1" {
|
||||
t.Fatalf("unexpected request: %#v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReminderRequestParsesRelativeMinutes(t *testing.T) {
|
||||
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60))
|
||||
request, err := buildReminderRequest("Напомни мне через 3 минуты проверить уведомление.", identity.CreatorSession{PrincipalID: "principal-1"}, interaction.OriginalHumanIntent{ID: "intent-1"}, interaction.HumanUtterance{ID: "utterance-1"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trigger := time.UnixMilli(request.TriggerAtUnixMillis).In(now.Location())
|
||||
if !trigger.Equal(now.Add(3 * time.Minute)) {
|
||||
t.Fatalf("unexpected trigger: %s", trigger)
|
||||
}
|
||||
if request.Summary != "проверить уведомление" {
|
||||
t.Fatalf("unexpected summary: %q", request.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuityReminderClientDoesNotAcceptFalseSuccess(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/reminders" || r.Header.Get("Authorization") != "Bearer session" {
|
||||
t.Fatal("unexpected reminder request")
|
||||
}
|
||||
var request continuity.ScheduleReminderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Summary != "позвонить врачу" {
|
||||
t.Fatalf("bad payload: %#v err=%v", request, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(continuity.ScheduleReminderResult{Item: continuity.ProspectiveItem{ID: "reminder-1", State: continuity.ProspectiveArmed, Summary: request.Summary}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
client, err := newContinuityReminderClient(upstream.URL, upstream.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.Schedule(context.Background(), "Bearer session", continuity.ScheduleReminderRequest{Summary: "позвонить врачу"})
|
||||
if err != nil || result.Item.ID != "reminder-1" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderSummaryRemovesSchedulingWords(t *testing.T) {
|
||||
if summary := reminderSummary("Напомни завтра в 10 позвонить врачу."); summary != "позвонить врачу" || strings.Contains(summary, "10") {
|
||||
t.Fatalf("summary = %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSoftwareAgentInstructionKeepsHumanIntentSeparateFromDerivedContext(t *testing.T) {
|
||||
instruction := buildSoftwareAgentInstruction(
|
||||
work.PlanningResult{
|
||||
Goal: work.Goal{DesiredOutcome: "fix chat wording"},
|
||||
Candidate: work.PlanCandidate{Summary: "accepted deterministic software change plan", Steps: []work.PlanStep{{Description: "Inspect chat surface"}, {Description: "Change copy"}, {Description: "Run tests"}}},
|
||||
},
|
||||
interaction.OriginalHumanIntent{ID: "intent-1", Text: "Исправь текст в чате Assistant."},
|
||||
interaction.HumanUtterance{ID: "utterance-1", Content: "Исправь текст в чате Assistant."},
|
||||
)
|
||||
if !strings.Contains(instruction, "Human asked: Исправь текст в чате Assistant.") {
|
||||
t.Fatalf("instruction must preserve human intent: %q", instruction)
|
||||
}
|
||||
if !strings.Contains(instruction, "Verification: run relevant tests") || !strings.Contains(instruction, "Plan summary: accepted deterministic software change plan") {
|
||||
t.Fatalf("instruction must include derived execution context: %q", instruction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowCanaryForChatCapabilityIsLimitedToSoftwareEngineeringPath(t *testing.T) {
|
||||
if !allowCanaryForChatCapability(capability.CapabilityKindModifyCode) || !allowCanaryForChatCapability(capability.CapabilityKindRunTests) || !allowCanaryForChatCapability(capability.CapabilityKindInspectProject) {
|
||||
t.Fatal("software-engineering capabilities must allow canary implementations")
|
||||
}
|
||||
if allowCanaryForChatCapability(capability.CapabilityKindScheduleReminder) {
|
||||
t.Fatal("non software-engineering capability must not implicitly allow canary")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type cognitiveInterpretationClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return cognitiveInterpretationClient{}, fmt.Errorf("invalid Cognitive Runtime URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return cognitiveInterpretationClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request cognitive.GatewayInterpretationRequest) (cognitive.GatewayInterpretationResponse, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/interpretations"
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return cognitive.GatewayInterpretationResponse{}, err
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return cognitive.GatewayInterpretationResponse{}, err
|
||||
}
|
||||
upstream.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(upstream)
|
||||
if err != nil {
|
||||
return cognitive.GatewayInterpretationResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return cognitive.GatewayInterpretationResponse{}, fmt.Errorf("cognitive interpretation status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayInterpretationResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return cognitive.GatewayInterpretationResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
|
||||
// The built-in advisory channel is the default conversational path. A
|
||||
// simple question must not first wait for the slower, strict JSON
|
||||
// interpretation route: that route exists for ambiguity and work routing,
|
||||
// not as a prerequisite for talking to Domovoy.
|
||||
if isSimpleLocalQuestion(result) {
|
||||
return false
|
||||
}
|
||||
switch result.Interpretation.Applicability {
|
||||
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion || result.Interpretation.Clarification == interaction.ClarificationRequired {
|
||||
return false
|
||||
}
|
||||
for _, hint := range result.CapabilityHints {
|
||||
switch hint {
|
||||
case "MODIFY_CODE", "RUN_TESTS", "SEND_EMAIL", "SCHEDULE_REMINDER", "READ_DATABASE", "QUERY_LOGS":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildGatewayInterpretationRequest(session identity.CreatorSession, utterance interaction.HumanUtterance, semantic interactiondomain.SemanticResult, projectNames []string) cognitive.GatewayInterpretationRequest {
|
||||
requestID, _ := ids.New()
|
||||
return cognitive.GatewayInterpretationRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID,
|
||||
PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: utterance.Content,
|
||||
Locale: "ru",
|
||||
MaxOutputBytes: 4096,
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
DeterministicSpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
DeterministicSecondary: string(semantic.Interpretation.SecondarySpeechAct),
|
||||
DeterministicConfidence: semantic.Interpretation.ConfidenceMillis,
|
||||
DeterministicApplicability: cognitive.InterpretationApplicability(semantic.Interpretation.Applicability),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
ProjectNames: projectNames,
|
||||
}
|
||||
}
|
||||
|
||||
func interactionCandidateFromGateway(utteranceID interaction.HumanUtteranceID, candidate cognitive.GatewayInterpretationCandidate, now interface{ UnixMilli() int64 }) (interaction.SemanticCandidate, error) {
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.SemanticCandidate{}, err
|
||||
}
|
||||
return interaction.SemanticCandidate{
|
||||
ID: interaction.SemanticCandidateID(id),
|
||||
UtteranceID: utteranceID,
|
||||
Source: interaction.CandidateSourceCognitive,
|
||||
SelectionState: interaction.CandidateSelected,
|
||||
PrimarySpeechAct: interaction.SpeechAct(candidate.PrimarySpeechAct),
|
||||
SecondarySpeechAct: interaction.SpeechAct(candidate.SecondarySpeechAct),
|
||||
ConfidenceMillis: candidate.ConfidenceMillis,
|
||||
Applicability: interaction.SemanticApplicability(candidate.Applicability),
|
||||
RequestedOutcome: candidate.RequestedOutcome,
|
||||
Clarification: interaction.ClarificationState(candidate.Clarification),
|
||||
NeedsCognition: candidate.NeedsCognition,
|
||||
SelectionReason: candidate.SelectionReason,
|
||||
CapabilityHints: capabilityHintStrings(candidate.CapabilityHints),
|
||||
RecordedAtUnixMillis: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func capabilityHintStrings(values []capability.CapabilityKind) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, string(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActQuestion,
|
||||
Applicability: interaction.SemanticNeedsBroaderInterpret,
|
||||
Clarification: interaction.ClarificationNotRequired,
|
||||
},
|
||||
CapabilityHints: []string{"COGNITIVE_REASONING", "VERIFY_FACT"},
|
||||
}
|
||||
if shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("simple question must go directly to the built-in advisory path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActQuestion,
|
||||
Applicability: interaction.SemanticNeedsBroaderInterpret,
|
||||
Clarification: interaction.ClarificationNotRequired,
|
||||
},
|
||||
CapabilityHints: []string{"MODIFY_CODE", "RUN_TESTS"},
|
||||
}
|
||||
if !shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("operational question must retain strict interpretation routing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"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"
|
||||
)
|
||||
|
||||
const reminderResponseSkillID = capability.SkillProfileID("skill-obvious-reminder-response-v1")
|
||||
|
||||
type learningEpistemicsStore interface {
|
||||
SaveSystemExpectation(context.Context, epistemics.SystemExpectation) error
|
||||
SaveExperienceRecord(context.Context, epistemics.ExperienceRecord) error
|
||||
SaveReflectionCandidate(context.Context, epistemics.ReflectionCandidate) error
|
||||
}
|
||||
|
||||
type learningCapabilityStore interface {
|
||||
LoadSkillProfile(context.Context, capability.SkillProfileID) (capability.SkillProfile, error)
|
||||
SaveSkillProfile(context.Context, capability.SkillProfile) error
|
||||
}
|
||||
|
||||
type learningRecorder struct {
|
||||
epistemics learningEpistemicsStore
|
||||
capability capabilityLearningStore
|
||||
}
|
||||
|
||||
type capabilityLearningStore interface {
|
||||
learningCapabilityStore
|
||||
}
|
||||
|
||||
type reminderResponseLearningInput struct {
|
||||
Utterance interaction.HumanUtterance
|
||||
Intent interaction.OriginalHumanIntent
|
||||
Result continuity.HumanResponseResult
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type correctionLearningInput struct {
|
||||
Utterance interaction.HumanUtterance
|
||||
Intent interaction.OriginalHumanIntent
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type fastPathDeclineLearningInput struct {
|
||||
Utterance interaction.HumanUtterance
|
||||
Intent interaction.OriginalHumanIntent
|
||||
Result continuity.HumanResponseResult
|
||||
Reason capability.FastPathDeclineReason
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type capabilityLearningInput struct {
|
||||
Utterance interaction.HumanUtterance
|
||||
Intent interaction.OriginalHumanIntent
|
||||
ImplementationID capability.CapabilityImplementationID
|
||||
UsageObservation capability.CapabilityUsageObservationID
|
||||
GapID capability.CapabilityGapID
|
||||
Outcome capability.UsageOutcomeClass
|
||||
OutcomeRef string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type delegationLearningInput struct {
|
||||
Intent interaction.OriginalHumanIntent
|
||||
Utterance interaction.HumanUtterance
|
||||
DelegationID string
|
||||
RunID string
|
||||
ImplementationID capability.CapabilityImplementationID
|
||||
OutcomeRef string
|
||||
VerificationRefs []epistemics.EvidenceRef
|
||||
Verified bool
|
||||
ExecutionWindows int
|
||||
HumanBlocked bool
|
||||
At time.Time
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordReminderResponse(ctx context.Context, input reminderResponseLearningInput) {
|
||||
if r.epistemics == nil || r.capability == nil || input.Intent.ID == "" || input.Utterance.ID == "" || input.Result.Response.ID == "" {
|
||||
return
|
||||
}
|
||||
expectedEffect := "resolve obvious reminder continuation using deterministic target resolver"
|
||||
if input.Result.Response.TargetProspectiveID != "" {
|
||||
expectedEffect += " for prospective item " + string(input.Result.Response.TargetProspectiveID)
|
||||
}
|
||||
expectation := epistemics.SystemExpectation{
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
ExpectedOutcome: epistemics.OutcomeSuccess,
|
||||
ExpectedEffect: expectedEffect,
|
||||
ConfidenceMillis: 850,
|
||||
EvidenceBasis: []epistemics.EvidenceRef{"continuity:response-target-resolution"},
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
expectation, err := epistemicsdomain.NewSystemExpectation(expectation)
|
||||
if err != nil {
|
||||
log.Printf("learning reminder expectation invalid: %v", err)
|
||||
return
|
||||
}
|
||||
if err := r.epistemics.SaveSystemExpectation(ctx, expectation); err != nil {
|
||||
log.Printf("learning reminder expectation unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
actual, confidence := outcomeForHumanResponse(input.Result.Response)
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceContinuity,
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
ContextRefs: contextRefs(
|
||||
"continuity:human-response:"+string(input.Result.Response.ID),
|
||||
"continuity:prospective:"+string(input.Result.Response.TargetProspectiveID),
|
||||
"continuity:attention:"+string(input.Result.Response.TargetAttentionID),
|
||||
),
|
||||
HelpMethod: epistemics.HelpMethodDeterministicFastPath,
|
||||
HelpMethodRef: "continuity:human-response-target-resolver-v1",
|
||||
SkillRef: epistemics.SkillRef(reminderResponseSkillID),
|
||||
ExpectationID: expectation.ID,
|
||||
ExpectedOutcome: expectation.ExpectedOutcome,
|
||||
ActualOutcome: actual,
|
||||
OutcomeRef: "continuity:human-response:" + string(input.Result.Response.ID),
|
||||
OutcomeDetail: string(input.Result.Response.Operation) + " -> " + string(input.Result.Response.Outcome),
|
||||
HumanResponseRef: epistemics.HumanResponseRef(input.Result.Response.ID),
|
||||
VerificationEvidence: []epistemics.EvidenceRef{epistemics.EvidenceRef("human-response:" + string(input.Result.Response.Outcome))},
|
||||
ConfidenceMillis: confidence,
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
r.saveExperienceAndReflection(ctx, experience)
|
||||
r.updateReminderSkill(ctx, experience, skillOutcomeForExperience(experience), false)
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordCorrection(ctx context.Context, input correctionLearningInput) {
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" {
|
||||
return
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceInteraction,
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
HelpMethod: epistemics.HelpMethodClarification,
|
||||
ExpectedOutcome: epistemics.OutcomeSuccess,
|
||||
ActualOutcome: epistemics.OutcomeUserCorrected,
|
||||
OutcomeDetail: "human correction indicates prior interpretation or target resolution may be wrong",
|
||||
CorrectionUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
VerificationEvidence: []epistemics.EvidenceRef{"interaction:human-correction"},
|
||||
ConfidenceMillis: 1000,
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
r.saveExperienceAndReflection(ctx, experience)
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastPathDeclineLearningInput) {
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" {
|
||||
return
|
||||
}
|
||||
experience, err := epistemicsdomain.FastPathDeclineExperience(
|
||||
epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
epistemics.SkillRef(reminderResponseSkillID),
|
||||
string(outcomeForDecline(input.Reason)),
|
||||
input.At.UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if input.Result.Response.ID != "" {
|
||||
experience.ContextRefs = contextRefs("continuity:response-candidate:" + string(input.Result.Response.Operation))
|
||||
}
|
||||
saved, ok := r.saveExperienceAndReflection(ctx, experience)
|
||||
if ok {
|
||||
r.updateReminderSkill(ctx, saved, capability.UsageSkipped, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabilityLearningInput) {
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" {
|
||||
return
|
||||
}
|
||||
actual := epistemics.OutcomeSuccess
|
||||
switch input.Outcome {
|
||||
case capability.UsageFailed:
|
||||
actual = epistemics.OutcomeFailed
|
||||
case capability.UsageSkipped:
|
||||
actual = epistemics.OutcomeNotApplicable
|
||||
case capability.UsageUnknown:
|
||||
actual = epistemics.OutcomeUnknown
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceCapability,
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
HelpMethod: epistemics.HelpMethodCapabilityExecution,
|
||||
CapabilityImplementationRef: epistemics.CapabilityImplementationRef(input.ImplementationID),
|
||||
CapabilityUsageObservationRef: epistemics.CapabilityUsageObservationRef(input.UsageObservation),
|
||||
CapabilityGapRef: epistemics.CapabilityGapRef(input.GapID),
|
||||
ExpectedOutcome: epistemics.OutcomeSuccess,
|
||||
ActualOutcome: actual,
|
||||
OutcomeRef: input.OutcomeRef,
|
||||
OutcomeDetail: "capability outcome " + string(input.Outcome),
|
||||
VerificationEvidence: []epistemics.EvidenceRef{"capability:usage-observation"},
|
||||
ConfidenceMillis: 900,
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
r.saveExperienceAndReflection(ctx, experience)
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input delegationLearningInput) {
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" || input.ImplementationID == "" {
|
||||
return
|
||||
}
|
||||
actual := epistemics.OutcomeSuccess
|
||||
confidence := int64(950)
|
||||
if !input.Verified {
|
||||
actual = epistemics.OutcomeFailed
|
||||
confidence = 850
|
||||
}
|
||||
detailParts := []string{"delegation outcome recorded"}
|
||||
if input.ExecutionWindows > 1 {
|
||||
detailParts = append(detailParts, "execution-window-continuations="+strconv.Itoa(input.ExecutionWindows-1))
|
||||
}
|
||||
if input.HumanBlocked {
|
||||
detailParts = append(detailParts, "human-blocker-occurred")
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceCapability,
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
ContextRefs: contextRefs("work:delegation:"+input.DelegationID, "work:run:"+input.RunID),
|
||||
HelpMethod: epistemics.HelpMethodCapabilityExecution,
|
||||
HelpMethodRef: "work:agent-delegation-v1",
|
||||
CapabilityImplementationRef: epistemics.CapabilityImplementationRef(input.ImplementationID),
|
||||
ExpectedOutcome: epistemics.OutcomeSuccess,
|
||||
ActualOutcome: actual,
|
||||
OutcomeRef: input.OutcomeRef,
|
||||
OutcomeDetail: strings.Join(detailParts, "; "),
|
||||
VerificationEvidence: input.VerificationRefs,
|
||||
ConfidenceMillis: confidence,
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
r.saveExperienceAndReflection(ctx, experience)
|
||||
}
|
||||
|
||||
func (r learningRecorder) saveExperienceAndReflection(ctx context.Context, value epistemics.ExperienceRecord) (epistemics.ExperienceRecord, bool) {
|
||||
value, err := epistemicsdomain.NewExperienceRecord(value)
|
||||
if err != nil {
|
||||
log.Printf("learning experience invalid: %v", err)
|
||||
return epistemics.ExperienceRecord{}, false
|
||||
}
|
||||
if err := r.epistemics.SaveExperienceRecord(ctx, value); err != nil {
|
||||
log.Printf("learning experience unavailable: %v", err)
|
||||
return epistemics.ExperienceRecord{}, false
|
||||
}
|
||||
if candidate, ok, err := epistemicsdomain.ReflectionForExperience(value); err == nil && ok {
|
||||
if err := r.epistemics.SaveReflectionCandidate(ctx, candidate); err != nil {
|
||||
log.Printf("learning reflection unavailable: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("learning reflection invalid: %v", err)
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (r learningRecorder) updateReminderSkill(ctx context.Context, experience epistemics.ExperienceRecord, outcome capability.UsageOutcomeClass, corrected bool) {
|
||||
if r.capability == nil || experience.ID == "" {
|
||||
return
|
||||
}
|
||||
profile, err := r.capability.LoadSkillProfile(ctx, reminderResponseSkillID)
|
||||
if err != nil {
|
||||
log.Printf("learning skill profile unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
next, err := capabilitydomain.ObserveSkillOutcome(profile, capability.SkillOutcomeObservation{
|
||||
ExperienceRef: capability.SkillEvidenceRef("epistemics:experience:" + string(experience.ID)),
|
||||
Outcome: outcome,
|
||||
UserCorrected: corrected,
|
||||
ObservedAtUnixMillis: experience.RecordedAtUnixMillis,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("learning skill observation invalid: %v", err)
|
||||
return
|
||||
}
|
||||
if err := r.capability.SaveSkillProfile(ctx, next); err != nil {
|
||||
log.Printf("learning skill profile save unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func outcomeForHumanResponse(response continuity.HumanResponse) (epistemics.OutcomeClass, int64) {
|
||||
if response.ResolutionState != continuity.ResponseResolved {
|
||||
switch response.ResolutionState {
|
||||
case continuity.ResponseAmbiguous:
|
||||
return epistemics.OutcomeConflict, 850
|
||||
case continuity.ResponseNoTarget:
|
||||
return epistemics.OutcomeOutOfDistribution, 850
|
||||
default:
|
||||
return epistemics.OutcomeUnexpected, 700
|
||||
}
|
||||
}
|
||||
switch response.Outcome {
|
||||
case continuity.ResponseOutcomeUserReportedComplete, continuity.ResponseOutcomeSnoozed, continuity.ResponseOutcomeCancelled, continuity.ResponseOutcomeDismissed:
|
||||
return epistemics.OutcomeSuccess, 950
|
||||
case continuity.ResponseOutcomeClarificationNeeded:
|
||||
return epistemics.OutcomeConflict, 850
|
||||
default:
|
||||
return epistemics.OutcomeUnknown, 500
|
||||
}
|
||||
}
|
||||
|
||||
func skillOutcomeForExperience(experience epistemics.ExperienceRecord) capability.UsageOutcomeClass {
|
||||
switch experience.ActualOutcome {
|
||||
case epistemics.OutcomeSuccess, epistemics.OutcomePartialSuccess:
|
||||
return capability.UsageSucceeded
|
||||
case epistemics.OutcomeFailed, epistemics.OutcomeUnexpected, epistemics.OutcomeUserCorrected:
|
||||
return capability.UsageFailed
|
||||
case epistemics.OutcomeUnknown:
|
||||
return capability.UsageUnknown
|
||||
default:
|
||||
return capability.UsageSkipped
|
||||
}
|
||||
}
|
||||
|
||||
func outcomeForDecline(reason capability.FastPathDeclineReason) epistemics.OutcomeClass {
|
||||
switch reason {
|
||||
case capability.FastPathLowConfidence:
|
||||
return epistemics.OutcomeLowConfidence
|
||||
case capability.FastPathOutOfDistribution:
|
||||
return epistemics.OutcomeOutOfDistribution
|
||||
case capability.FastPathConflict:
|
||||
return epistemics.OutcomeConflict
|
||||
case capability.FastPathStale:
|
||||
return epistemics.OutcomeStale
|
||||
default:
|
||||
return epistemics.OutcomeNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
func contextRefs(values ...string) []epistemics.ContextRef {
|
||||
refs := make([]epistemics.ContextRef, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasSuffix(value, ":") {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, epistemics.ContextRef(value))
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func looksLikeUnsupportedContinuation(message string, result continuity.HumanResponseResult) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
if result.Handled {
|
||||
return false
|
||||
}
|
||||
if result.Response.Operation == continuity.ResponseContinue || result.Response.Operation == continuity.ResponseReject {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "это") && (strings.Contains(lower, "напомин") || strings.Contains(lower, "перенеси") || strings.Contains(lower, "закрой") || strings.Contains(lower, "передвинь"))
|
||||
}
|
||||
|
||||
func declineReasonForResponse(result continuity.HumanResponseResult) capability.FastPathDeclineReason {
|
||||
switch result.Response.Operation {
|
||||
case continuity.ResponseReject:
|
||||
return capability.FastPathConflict
|
||||
case continuity.ResponseContinue:
|
||||
return capability.FastPathNotApplicable
|
||||
default:
|
||||
return capability.FastPathOutOfDistribution
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
type learningEpistemicsStub struct {
|
||||
experiences []epistemics.ExperienceRecord
|
||||
}
|
||||
|
||||
func (s *learningEpistemicsStub) SaveSystemExpectation(context.Context, epistemics.SystemExpectation) error {
|
||||
return nil
|
||||
}
|
||||
func (s *learningEpistemicsStub) SaveExperienceRecord(_ context.Context, value epistemics.ExperienceRecord) error {
|
||||
s.experiences = append(s.experiences, value)
|
||||
return nil
|
||||
}
|
||||
func (s *learningEpistemicsStub) SaveReflectionCandidate(context.Context, epistemics.ReflectionCandidate) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type learningCapabilityStub struct {
|
||||
loadCalls int
|
||||
saveCalls int
|
||||
}
|
||||
|
||||
func (s *learningCapabilityStub) LoadSkillProfile(context.Context, capability.SkillProfileID) (capability.SkillProfile, error) {
|
||||
s.loadCalls++
|
||||
return capability.SkillProfile{}, nil
|
||||
}
|
||||
func (s *learningCapabilityStub) SaveSkillProfile(context.Context, capability.SkillProfile) error {
|
||||
s.saveCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGenericCorrectionDoesNotDegradeReminderSkill(t *testing.T) {
|
||||
epistemicsStore := &learningEpistemicsStub{}
|
||||
capabilityStore := &learningCapabilityStub{}
|
||||
recorder := learningRecorder{epistemics: epistemicsStore, capability: capabilityStore}
|
||||
recorder.recordCorrection(context.Background(), correctionLearningInput{
|
||||
Intent: interaction.OriginalHumanIntent{ID: "intent-1"},
|
||||
Utterance: interaction.HumanUtterance{ID: "utterance-1"},
|
||||
At: time.UnixMilli(1000),
|
||||
})
|
||||
if capabilityStore.loadCalls != 0 || capabilityStore.saveCalls != 0 {
|
||||
t.Fatalf("generic correction must not mutate reminder skill: load=%d save=%d", capabilityStore.loadCalls, capabilityStore.saveCalls)
|
||||
}
|
||||
if len(epistemicsStore.experiences) != 1 || epistemicsStore.experiences[0].SkillRef != "" {
|
||||
t.Fatalf("generic correction must remain evidence without a reminder skill reference: %#v", epistemicsStore.experiences)
|
||||
}
|
||||
}
|
||||
+757
-11
@@ -6,7 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
@@ -16,6 +20,7 @@ import (
|
||||
identitydomain "git.cin.su/m/Assistant/modules/identity/domain"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -64,6 +69,19 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer capabilityRepository.Close()
|
||||
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
epistemicsUser := os.Getenv("EPISTEMICS_POSTGRES_USER")
|
||||
if epistemicsUser == "" {
|
||||
epistemicsUser = "epistemics_role"
|
||||
}
|
||||
epistemicsRepository, err := postgres.OpenEpistemics(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: epistemicsUser, Password: epistemicsPassword})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer epistemicsRepository.Close()
|
||||
identityRepository, err := postgres.OpenIdentity(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("IDENTITY_POSTGRES_USER"), Password: os.Getenv("IDENTITY_POSTGRES_PASSWORD")})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -82,6 +100,23 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer interactionRepository.Close()
|
||||
reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
responseClient, err := newContinuityResponseClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
attentionClient, err := newContinuityAttentionClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 20 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository}
|
||||
mux := http.NewServeMux()
|
||||
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
||||
@@ -100,6 +135,40 @@ func main() {
|
||||
return situationdomain.NewResolver(bindings).Resolve(runtime)
|
||||
}
|
||||
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{
|
||||
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"),
|
||||
}})
|
||||
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) {
|
||||
var request struct {
|
||||
LeaseID string `json:"lease_id"`
|
||||
Request capability.AdmissionRequest `json:"request"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&request); err != nil || request.LeaseID == "" {
|
||||
http.Error(w, "invalid admission request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decision, lease, err := capabilityRepository.AdmitCognitive(r.Context(), request.LeaseID, admissionPolicy, request.Request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "admission unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Decision capability.AdmissionDecision `json:"decision"`
|
||||
Lease capability.AdmissionLease `json:"lease"`
|
||||
}{decision, lease})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/internal/cognitive/admission/{lease}/release", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := capabilityRepository.ReleaseCognitiveAdmission(r.Context(), capability.AdmissionLease{ID: r.PathValue("lease")}, time.Now().UnixMilli()); err != nil {
|
||||
http.Error(w, "admission release unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
|
||||
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
@@ -160,6 +229,45 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/learning/recent", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
limit := 10
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 50 {
|
||||
http.Error(w, "invalid learning limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
experiences, err := epistemicsRepository.RecentExperienceRecords(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("learning recent experiences unavailable: %v", err)
|
||||
http.Error(w, "learning experience unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
reflections, err := epistemicsRepository.RecentReflectionCandidates(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("learning recent reflections unavailable: %v", err)
|
||||
http.Error(w, "learning reflection unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
skills, err := capabilityRepository.RecentSkillProfiles(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("learning recent skills unavailable: %v", err)
|
||||
http.Error(w, "learning skills unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Experiences []epistemics.ExperienceRecord
|
||||
Reflections []epistemics.ReflectionCandidate
|
||||
Skills []capability.SkillProfile
|
||||
}{Experiences: experiences, Reflections: reflections, Skills: skills})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -219,14 +327,43 @@ func main() {
|
||||
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
cognitiveAttempted := false
|
||||
cognitiveAvailable := true
|
||||
if shouldRequestBroaderInterpretation(semantic) {
|
||||
cognitiveAttempted = true
|
||||
projectNames := make([]string, 0, len(semantic.Projects))
|
||||
for _, ref := range semantic.Projects {
|
||||
if ref.ProjectDisplayName != "" {
|
||||
projectNames = append(projectNames, ref.ProjectDisplayName)
|
||||
}
|
||||
}
|
||||
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
|
||||
if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil {
|
||||
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus)
|
||||
} else {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
} else {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
}
|
||||
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
|
||||
http.Error(w, "save chat message", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := interactionRepository.SaveSemanticResult(r.Context(), semantic.Interpretation, semantic.Projects, semantic.Relations); err != nil {
|
||||
if err := interactionRepository.SaveSemanticResult(r.Context(), semantic.Interpretation, semantic.Candidates, semantic.Projects, semantic.Relations); err != nil {
|
||||
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: utterance.Content,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||
learning.recordCorrection(r.Context(), correctionLearningInput{Utterance: utterance, Intent: intent, At: now})
|
||||
}
|
||||
activeFocusName := ""
|
||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||
if semantic.ActiveFocus != nil {
|
||||
@@ -236,26 +373,387 @@ func main() {
|
||||
}
|
||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||
} else if previousFocus != nil {
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus && previousFocus != nil {
|
||||
activeFocusName = previousFocus.ProjectDisplayName
|
||||
activeFocusProvenance = previousFocus.Provenance
|
||||
}
|
||||
var planningReply string
|
||||
workPlanned := false
|
||||
needsCognition := semantic.NeedsCognition
|
||||
projectID := ""
|
||||
if semantic.ActiveFocus != nil {
|
||||
projectID = string(semantic.ActiveFocus.ProjectID)
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
|
||||
projectID = string(previousFocus.ProjectID)
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
planningReply = responseResult.Reply
|
||||
needsCognition = false
|
||||
learning.recordReminderResponse(r.Context(), reminderResponseLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, At: now})
|
||||
if resumed, err := continueDelegationFromHumanResponse(r.Context(), workRepository, responseResult.Response, now); err == nil && resumed {
|
||||
planningReply = "Принял. Продолжаю ту же задачу coding-agent без потери контекста."
|
||||
}
|
||||
} else if responseErr == nil && looksLikeUnsupportedContinuation(utterance.Content, responseResult) {
|
||||
learning.recordFastPathDecline(r.Context(), fastPathDeclineLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, Reason: declineReasonForResponse(responseResult), At: now})
|
||||
}
|
||||
planningInput := work.PlanningInput{
|
||||
ConversationID: work.ConversationRef(conversation.ID),
|
||||
UtteranceID: work.SourceUtteranceRef(utterance.ID),
|
||||
IntentID: work.SourceIntentRef(intent.ID),
|
||||
ActorID: session.ActorID,
|
||||
PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
ProjectID: work.ProjectRef(projectID),
|
||||
ProjectDisplayName: activeFocusName,
|
||||
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
|
||||
Message: utterance.Content,
|
||||
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
|
||||
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
|
||||
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
NeedsCognition: semantic.NeedsCognition,
|
||||
RequiresClarification: semantic.Interpretation.Clarification == interaction.ClarificationRequired,
|
||||
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
}
|
||||
helpPath := workdomain.DetermineHelpPath(planningInput)
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
switch semantic.InformationRequestKind {
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||
if err == nil && len(kinds) > 0 {
|
||||
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
|
||||
} else {
|
||||
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
|
||||
}
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
candidates, inventoryErr := capabilityRepository.LoadCognitiveCandidates(r.Context(), capability.CapabilityID("creator-advisory"), session.PrincipalID)
|
||||
planningReply = humanCognitiveModelInventory(candidates, inventoryErr)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery:
|
||||
health, healthErr := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID("cognitive-proposal"))
|
||||
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
|
||||
needsCognition = false
|
||||
}
|
||||
if helpPath.Reply != "" {
|
||||
planningReply = helpPath.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
||||
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
||||
needsCognition = false
|
||||
}
|
||||
var capabilityStatus capability.CapabilityResolutionStatus
|
||||
var capabilityKind capability.CapabilityKind
|
||||
var capabilityGapID capability.CapabilityGapID
|
||||
var delegationID work.DelegationID
|
||||
var delegationState work.DelegationState
|
||||
var reminder *continuity.ProspectiveItem
|
||||
if helpPath.Kind == work.HelpPathDirectCapability && len(helpPath.SuggestedCapabilityKinds) > 0 {
|
||||
capabilityKind = helpPath.SuggestedCapabilityKinds[0]
|
||||
started := time.Now()
|
||||
resolution, resolveErr := resolveChatCapability(r.Context(), capabilityRepository, capabilityKind, session, intent, utterance, now)
|
||||
capabilityStatus = resolution.Status
|
||||
if resolveErr == nil && len(resolution.Eligible) > 0 {
|
||||
reminderRequest, reminderErr := buildReminderRequest(utterance.Content, session, intent, utterance, now)
|
||||
if reminderErr == nil {
|
||||
result, scheduleErr := reminderClient.Schedule(r.Context(), r.Header.Get("Authorization"), reminderRequest)
|
||||
finished := time.Now()
|
||||
if scheduleErr == nil {
|
||||
reminder = &result.Item
|
||||
planningReply = fmt.Sprintf("Готово. Я создал напоминание: %s.", result.Item.Summary)
|
||||
usageID, _ := observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageSucceeded, string(result.Item.ID), started, finished)
|
||||
learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, ImplementationID: resolution.Eligible[0].Implementation.ID, UsageObservation: usageID, Outcome: capability.UsageSucceeded, OutcomeRef: "continuity:prospective:" + string(result.Item.ID), At: finished})
|
||||
} else {
|
||||
planningReply = "Scheduling capability сейчас недоступна: напоминание не создано. Я не буду притворяться, что действие выполнено."
|
||||
usageID, _ := observeCapabilityUsage(r.Context(), capabilityRepository, resolution.Eligible[0], resolution.Requirement, capability.UsageFailed, "continuity-reminder-unavailable", started, finished)
|
||||
learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, ImplementationID: resolution.Eligible[0].Implementation.ID, UsageObservation: usageID, Outcome: capability.UsageFailed, OutcomeRef: "continuity-reminder-unavailable", At: finished})
|
||||
}
|
||||
} else {
|
||||
planningReply = "Мне нужно уточнить время напоминания. Например: «Напомни завтра в 10 позвонить врачу»."
|
||||
capabilityStatus = capability.ResolutionPartiallyAvailable
|
||||
}
|
||||
} else if resolution.Gap != nil {
|
||||
capabilityGapID = resolution.Gap.ID
|
||||
planningReply = "Сейчас scheduling capability недоступна, поэтому напоминание не создано. Я зафиксировал capability gap."
|
||||
learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, GapID: resolution.Gap.ID, Outcome: capability.UsageSkipped, OutcomeRef: "capability:gap:" + string(resolution.Gap.ID), At: now})
|
||||
}
|
||||
needsCognition = false
|
||||
}
|
||||
if helpPath.Kind == work.HelpPathCapabilityGap && len(helpPath.SuggestedCapabilityKinds) > 0 {
|
||||
capabilityKind = helpPath.SuggestedCapabilityKinds[0]
|
||||
resolution, resolveErr := resolveChatCapability(r.Context(), capabilityRepository, capabilityKind, session, intent, utterance, now)
|
||||
capabilityStatus = resolution.Status
|
||||
if resolveErr == nil {
|
||||
planningReply = "Capability найдена, но в этом шаге она не исполняется без отдельного разрешённого action path."
|
||||
} else if resolution.Gap != nil {
|
||||
capabilityGapID = resolution.Gap.ID
|
||||
planningReply = "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксировал capability gap, без ложного выполнения."
|
||||
learning.recordCapabilityUse(r.Context(), capabilityLearningInput{Utterance: utterance, Intent: intent, GapID: resolution.Gap.ID, Outcome: capability.UsageSkipped, OutcomeRef: "capability:gap:" + string(resolution.Gap.ID), At: now})
|
||||
}
|
||||
needsCognition = false
|
||||
}
|
||||
if helpPath.NeedsDurableWork {
|
||||
planning, err := workdomain.BuildPlanningResult(planningInput, now)
|
||||
if err != nil {
|
||||
http.Error(w, "plan chat work", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SavePlanningResult(r.Context(), planning); err != nil {
|
||||
http.Error(w, "save work plan", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
planningReply = planning.Reply
|
||||
workPlanned = true
|
||||
needsCognition = false
|
||||
if created, resolution, createErr := maybeCreateAgentDelegation(r.Context(), workRepository, capabilityRepository, session, conversation, intent, utterance, planning, now); createErr == nil && created != nil {
|
||||
delegationID = created.Delegation.ID
|
||||
delegationState = created.Delegation.State
|
||||
capabilityKind = created.Delegation.CapabilityKind
|
||||
capabilityStatus = resolution.Status
|
||||
planningReply = "Понял задачу. Я собрал work item, выбрал coding-agent в CANARY-режиме и создал delegation. Буду вести её как отдельный run с checkpoint/resume, без потери исходного human intent."
|
||||
} else if resolution.Gap != nil {
|
||||
capabilityKind = resolution.Requirement.CapabilityKind
|
||||
capabilityStatus = resolution.Status
|
||||
capabilityGapID = resolution.Gap.ID
|
||||
planningReply = planningReply + " Для coding delegation подходящий agent implementation сейчас недоступен, поэтому я сохранил план без ложного запуска."
|
||||
}
|
||||
}
|
||||
response := interaction.ChatMessageResponse{
|
||||
ConversationID: conversation.ID,
|
||||
UtteranceID: utterance.ID,
|
||||
SpeechAct: semantic.Interpretation.SpeechAct,
|
||||
InterpretationState: utterance.InterpretationState,
|
||||
NeedsCognition: semantic.NeedsCognition,
|
||||
FallbackText: semantic.Reply,
|
||||
ActiveProjectName: activeFocusName,
|
||||
ProjectProvenance: activeFocusProvenance,
|
||||
Clarification: semantic.Interpretation.Clarification,
|
||||
ConversationID: conversation.ID,
|
||||
UtteranceID: utterance.ID,
|
||||
OriginalIntentID: intent.ID,
|
||||
SpeechAct: semantic.Interpretation.SpeechAct,
|
||||
InterpretationState: utterance.InterpretationState,
|
||||
NeedsCognition: needsCognition,
|
||||
FallbackText: semantic.Reply,
|
||||
ActiveProjectName: activeFocusName,
|
||||
ProjectProvenance: activeFocusProvenance,
|
||||
Clarification: semantic.Interpretation.Clarification,
|
||||
WorkPlanned: workPlanned,
|
||||
PlanningReply: planningReply,
|
||||
CapabilityStatus: capabilityStatus,
|
||||
CapabilityKind: capabilityKind,
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
InformationRequestKind: semantic.InformationRequestKind,
|
||||
InterpretationConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
CapabilityGapID: capabilityGapID,
|
||||
DelegationID: string(delegationID),
|
||||
DelegationState: string(delegationState),
|
||||
Reminder: reminder,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/delegations/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
value, err := workRepository.LoadDelegation(r.Context(), work.DelegationID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
http.Error(w, "delegation not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/delegations/{id}/latest-checkpoint", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
value, err := workRepository.LatestAgentCheckpoint(r.Context(), work.DelegationID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
http.Error(w, "checkpoint not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/delegations/{id}/runs/{run}/checkpoints", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
BlockerClass work.AgentBlockerClass
|
||||
CompletedSummary string
|
||||
RemainingSummary string
|
||||
TestSummary string
|
||||
BranchName string
|
||||
CommitHash string
|
||||
NextStep string
|
||||
ArtifactRefs []work.EvidenceRef
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid checkpoint", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
run, err := workRepository.LoadAgentRun(r.Context(), work.AgentRunID(r.PathValue("run")))
|
||||
if err != nil || run.DelegationID != work.DelegationID(r.PathValue("id")) {
|
||||
http.Error(w, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
updatedRun, checkpoint, err := workdomain.CheckpointRun(run, request.BlockerClass, request.CompletedSummary, request.RemainingSummary, request.TestSummary, request.BranchName, request.CommitHash, request.NextStep, request.ArtifactRefs, time.UnixMilli(request.RecordedAtUnixMillis))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid checkpoint", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveAgentCheckpoint(r.Context(), updatedRun, checkpoint); err != nil {
|
||||
http.Error(w, "save checkpoint", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if request.BlockerClass == work.AgentBlockerHumanDecisionRequired {
|
||||
delegation, err := workRepository.LoadDelegation(r.Context(), run.DelegationID)
|
||||
if err == nil {
|
||||
_, _ = attentionClient.Create(r.Context(), r.Header.Get("Authorization"), continuity.CreateAttentionRequest{
|
||||
OwnerPrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
SourceKind: continuity.AttentionSourceAgentDelegation,
|
||||
SourceExternalRef: delegationAttentionRef(delegation.ID),
|
||||
Level: continuity.AttentionActionRequired,
|
||||
Summary: "Нужен твой выбор по задаче coding-agent: " + firstNonEmpty(request.RemainingSummary, delegation.DesiredOutcome),
|
||||
DueAtUnixMillis: checkpoint.RecordedAtUnixMillis,
|
||||
CreatedAtUnixMillis: checkpoint.RecordedAtUnixMillis,
|
||||
})
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(checkpoint)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/delegations/{id}/runs/{run}/results", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Summary string
|
||||
OutcomeRef string
|
||||
VerificationSummary string
|
||||
ArtifactRefs []work.EvidenceRef
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid result", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
run, err := workRepository.LoadAgentRun(r.Context(), work.AgentRunID(r.PathValue("run")))
|
||||
if err != nil || run.DelegationID != work.DelegationID(r.PathValue("id")) {
|
||||
http.Error(w, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
updatedRun, result, err := workdomain.CompleteRun(run, request.Summary, request.OutcomeRef, request.VerificationSummary, request.ArtifactRefs, time.UnixMilli(request.RecordedAtUnixMillis))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid result", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveAgentResult(r.Context(), updatedRun, result); err != nil {
|
||||
http.Error(w, "save result", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/delegations/{id}/resume", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
delegation, err := workRepository.LoadDelegation(r.Context(), work.DelegationID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
http.Error(w, "delegation not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
run, err := workRepository.LoadAgentRun(r.Context(), delegation.LatestRunID)
|
||||
if err != nil {
|
||||
http.Error(w, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
checkpoint, err := workRepository.LatestAgentCheckpoint(r.Context(), delegation.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "checkpoint not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
updatedDelegation, resumedRun, err := workdomain.ResumeDelegationRun(delegation, run, checkpoint, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "resume not allowed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveResumedRun(r.Context(), updatedDelegation, resumedRun); err != nil {
|
||||
http.Error(w, "save resumed run", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(resumedRun)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/delegations/{id}/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Verified bool
|
||||
Summary string
|
||||
EvidenceRefs []work.EvidenceRef
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.RecordedAtUnixMillis < 1 {
|
||||
http.Error(w, "invalid verification", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
delegation, err := workRepository.LoadDelegation(r.Context(), work.DelegationID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
http.Error(w, "delegation not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
run, err := workRepository.LoadAgentRun(r.Context(), delegation.LatestRunID)
|
||||
if err != nil {
|
||||
http.Error(w, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
result, err := workRepository.LatestAgentResult(r.Context(), delegation.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "result not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
updatedDelegation, verification, err := workdomain.VerifyDelegationResult(delegation, run, result, request.Verified, request.Summary, request.EvidenceRefs, time.UnixMilli(request.RecordedAtUnixMillis))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid verification", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveDelegationVerification(r.Context(), updatedDelegation, verification); err != nil {
|
||||
http.Error(w, "save verification", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runCount, _ := workRepository.CountAgentRuns(r.Context(), delegation.ID)
|
||||
learning.recordDelegationOutcome(r.Context(), delegationLearningInput{
|
||||
Intent: interaction.OriginalHumanIntent{ID: interaction.OriginalHumanIntentID(delegation.SourceIntentID), Text: delegation.DesiredOutcome},
|
||||
Utterance: interaction.HumanUtterance{ID: interaction.HumanUtteranceID(delegation.SourceUtteranceID), Content: delegation.DesiredOutcome},
|
||||
DelegationID: string(delegation.ID),
|
||||
RunID: string(run.ID),
|
||||
ImplementationID: run.ImplementationID,
|
||||
OutcomeRef: result.OutcomeRef,
|
||||
VerificationRefs: evidenceRefsToEpistemics(request.EvidenceRefs),
|
||||
Verified: request.Verified,
|
||||
ExecutionWindows: runCount,
|
||||
HumanBlocked: false,
|
||||
At: time.UnixMilli(request.RecordedAtUnixMillis),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(verification)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/principal-bindings/{actor}/{principal}", func(w http.ResponseWriter, r *http.Request) {
|
||||
binding, err := identityRepository.LoadBinding(r.Context(), identity.ActorID(r.PathValue("actor")), identity.PrincipalID(r.PathValue("principal")))
|
||||
if err != nil {
|
||||
@@ -371,6 +869,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
if err := capabilityRepository.SaveCognitiveDiscovery(r.Context(), registration); err != nil {
|
||||
log.Printf("cognitive discovery persistence failed: %v", err)
|
||||
http.Error(w, "save cognitive discovery", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -431,6 +930,45 @@ func main() {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(promotion)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/cognitive-activate", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request capability.CognitiveActivationPromotionRequest
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid cognitive activation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
request.RequestedByPrincipalID = session.PrincipalID
|
||||
request.CapabilityID = "creator-advisory"
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var current capability.ResolvedCognitiveBinding
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Binding.ID == request.BindingID && candidate.Binding.Revision == request.ExpectedBindingRevision {
|
||||
current = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
promotion, err := capabilitydomain.BuildActivationPromotion(request, current)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive activation precondition failed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion); err != nil {
|
||||
http.Error(w, "cognitive activation conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(promotion)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) {
|
||||
var value capability.PerformanceEvidence
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
|
||||
@@ -641,3 +1179,211 @@ func readCredentialRefSecret(name, refName string) (string, error) {
|
||||
Path: fileName,
|
||||
})
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(os.Getenv(name))
|
||||
if err != nil || value < 1 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
|
||||
if inventoryErr != nil {
|
||||
return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
|
||||
}
|
||||
lines := make([]string, 0, len(candidates)+1)
|
||||
lines = append(lines, "Подключённые модели из реестра:")
|
||||
for _, candidate := range candidates {
|
||||
modelName := string(candidate.Model.ProviderModelID)
|
||||
if modelName == "" {
|
||||
modelName = "имя модели не подтверждено"
|
||||
}
|
||||
status := cognitiveInventoryStatus(candidate)
|
||||
pricing := cognitiveInventoryPricing(candidate.Provider)
|
||||
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
|
||||
if pricing != "" {
|
||||
details = append(details, pricing)
|
||||
}
|
||||
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
|
||||
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
|
||||
}
|
||||
lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")")
|
||||
}
|
||||
lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
return "активна"
|
||||
}
|
||||
return "доступна, не активирована для автоматического выбора"
|
||||
}
|
||||
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
|
||||
return "работает с ограничениями"
|
||||
}
|
||||
return "недоступна или не подтверждена"
|
||||
}
|
||||
|
||||
func cognitiveInventoryPricing(provider capability.ProviderInstance) string {
|
||||
switch provider.BillingMode {
|
||||
case capability.BillingFree:
|
||||
return "бесплатная"
|
||||
case capability.BillingPerInput:
|
||||
return "платная: стоимость входа указана в реестре"
|
||||
case capability.BillingPerOutput:
|
||||
return "платная: стоимость выхода указана в реестре"
|
||||
case capability.BillingPerRequest:
|
||||
return "платная: стоимость запроса указана в реестре"
|
||||
default:
|
||||
return "стоимость не подтверждена"
|
||||
}
|
||||
}
|
||||
|
||||
func humanAuthMode(mode capability.AuthMode) string {
|
||||
switch mode {
|
||||
case capability.AuthKey:
|
||||
return "API-ключ"
|
||||
case capability.AuthBearer:
|
||||
return "Bearer-токен"
|
||||
case capability.AuthUsernamePassword:
|
||||
return "логин и пароль"
|
||||
case capability.AuthOAuth:
|
||||
return "OAuth"
|
||||
case capability.AuthSecretFile:
|
||||
return "защищённый файл секрета"
|
||||
default:
|
||||
return "защищённый способ"
|
||||
}
|
||||
}
|
||||
|
||||
func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act interaction.SpeechAct, health capability.Health, healthErr error) string {
|
||||
recoveryRequested := kind == interaction.InformationRequestCognitiveRecovery || act == interaction.SpeechActRequestToInvestigate || act == interaction.SpeechActDirective
|
||||
if healthErr != nil {
|
||||
if recoveryRequested {
|
||||
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
|
||||
}
|
||||
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
|
||||
}
|
||||
var observation string
|
||||
switch health.State {
|
||||
case capability.HealthAvailable:
|
||||
observation = "Основной локальный канал сейчас доступен."
|
||||
case capability.HealthDegraded:
|
||||
observation = "Основной локальный канал сейчас работает с ограничениями."
|
||||
case capability.HealthUnavailable:
|
||||
observation = "Основной локальный канал сейчас недоступен."
|
||||
default:
|
||||
observation = "Состояние основного локального канала пока не подтверждено."
|
||||
}
|
||||
if recoveryRequested {
|
||||
if health.State == capability.HealthUnavailable {
|
||||
return observation + " Домовой жив: начну с проверки встроенного маршрута, затем зафиксирую варианты аварийного восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Я не меняю модель прямо из разговора: сначала покажу наблюдаемое состояние и подготовлю следующий проверяемый шаг восстановления."
|
||||
}
|
||||
if health.State == capability.HealthUnavailable {
|
||||
if recoveryRequested {
|
||||
return observation + " Домовой не остановился: начну с проверки встроенного маршрута, затем зафиксирую доступные варианты восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал."
|
||||
}
|
||||
return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели."
|
||||
}
|
||||
|
||||
func humanCognitiveDegradedReply(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
|
||||
}
|
||||
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
|
||||
}
|
||||
|
||||
func humanCapabilityList(kinds []capability.CapabilityKind) string {
|
||||
labels := make([]string, 0, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
switch kind {
|
||||
case capability.CapabilityKindScheduleReminder:
|
||||
labels = append(labels, "создавать напоминания")
|
||||
case capability.CapabilityKindCognitiveReasoning:
|
||||
labels = append(labels, "отвечать через когнитивный gateway")
|
||||
default:
|
||||
labels = append(labels, strings.ToLower(strings.ReplaceAll(string(kind), "_", " ")))
|
||||
}
|
||||
}
|
||||
return strings.Join(labels, ", ")
|
||||
}
|
||||
|
||||
func toCapabilityKinds(values []string) []capability.CapabilityKind {
|
||||
result := make([]capability.CapabilityKind, 0, len(values))
|
||||
for _, value := range values {
|
||||
kind := capability.CapabilityKind(strings.TrimSpace(value))
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, kind)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func delegationAttentionRef(id work.DelegationID) string {
|
||||
return "work:delegation:" + string(id)
|
||||
}
|
||||
|
||||
func continueDelegationFromHumanResponse(ctx context.Context, repository *postgres.WorkRepository, response continuity.HumanResponse, now time.Time) (bool, error) {
|
||||
if response.Operation != continuity.ResponseContinue || !strings.HasPrefix(response.TargetExternalRef, "work:delegation:") {
|
||||
return false, nil
|
||||
}
|
||||
delegationID := work.DelegationID(strings.TrimPrefix(response.TargetExternalRef, "work:delegation:"))
|
||||
delegation, err := repository.LoadDelegation(ctx, delegationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
run, err := repository.LoadAgentRun(ctx, delegation.LatestRunID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
checkpoint, err := repository.LatestAgentCheckpoint(ctx, delegationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
updatedDelegation, resumedRun, err := workdomain.ResumeDelegationRun(delegation, run, checkpoint, now)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := repository.SaveResumedRun(ctx, updatedDelegation, resumedRun); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func evidenceRefsToEpistemics(values []work.EvidenceRef) []epistemics.EvidenceRef {
|
||||
result := make([]epistemics.EvidenceRef, 0, len(values))
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(string(value)) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, epistemics.EvidenceRef(value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
@@ -23,6 +27,58 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
|
||||
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
|
||||
t.Fatalf("unexpected safe reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") {
|
||||
t.Fatalf("unexpected configuration reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
|
||||
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
|
||||
t.Fatalf("unexpected degraded reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelInventoryQuestionUsesReadOnlyFastPath(t *testing.T) {
|
||||
for _, message := range []string{"какие модели доступны?", "напиши список настроек ИИ", "какие есть подключения ИИ"} {
|
||||
result, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: message}, nil, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("interpretation failed for %q: %v", message, err)
|
||||
}
|
||||
if result.InformationRequestKind != interaction.InformationRequestCognitiveModelInventory {
|
||||
t.Fatalf("semantic inventory request %q was not recognized: kind=%q act=%q hints=%v", message, result.InformationRequestKind, result.Interpretation.SpeechAct, result.CapabilityHints)
|
||||
}
|
||||
}
|
||||
ordinary, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: "настрой локальную модель"}, nil, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ordinary.InformationRequestKind == interaction.InformationRequestCognitiveModelInventory {
|
||||
t.Fatal("configuration mutation must not use read-only model inventory path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) {
|
||||
cost := int64(25)
|
||||
text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{{
|
||||
Binding: capability.CognitiveBinding{DataPolicy: capability.DataPolicyExternalMessageOnly, Activation: capability.ActivationActive},
|
||||
Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable},
|
||||
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
|
||||
}}, nil)
|
||||
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") {
|
||||
t.Fatalf("unsafe or incomplete inventory: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
|
||||
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
|
||||
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestChatPageServedWithoutInternalIDs(t *testing.T) {
|
||||
t.Setenv("ASSISTANT_BUILD_REVISION", "0123456789abcdef")
|
||||
t.Setenv("ASSISTANT_BUILD_TIME", "2026-08-22T03:04:05Z")
|
||||
handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -24,11 +27,62 @@ func TestChatPageServedWithoutInternalIDs(t *testing.T) {
|
||||
t.Fatalf("expected /chat status 200, got %d", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
for _, forbidden := range []string{"provider_id", "model_id", "binding_id", "workspace-id", "session-id"} {
|
||||
for _, required := range []string{"cognitive-status", "aria-live=\"polite\"", "cognitive-console-link", "href=\"/admin\"", "Проверь канал", "Изменить настройку", "ai-data-error", "Не вставляйте пароли", "build-meta", "версия ", "обновлено ", "datetime=\""} {
|
||||
if !strings.Contains(body, required) {
|
||||
t.Fatalf("/chat is missing human status marker %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"provider_id", "model_id", "binding_id", "workspace-id", "session-id", "__ASSISTANT_BUILD_REVISION__", "__ASSISTANT_BUILD_TIME__", "__ASSISTANT_BUILD_DATETIME__"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("/chat leaks internal field %q", forbidden)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "версия 0123456789ab") || !strings.Contains(body, "обновлено 22.08.2026 03:04 UTC") || !strings.Contains(body, "datetime=\"2026-08-22T03:04:05Z\"") {
|
||||
t.Fatalf("/chat does not render build metadata: %q", body)
|
||||
}
|
||||
for _, path := range []string{"/", "/chat.html"} {
|
||||
alternate := httptest.NewRecorder()
|
||||
handler.ServeHTTP(alternate, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if alternate.Code != http.StatusOK || strings.Contains(alternate.Body.String(), "__ASSISTANT_BUILD_REVISION__") {
|
||||
t.Fatalf("%s did not render build metadata: status=%d", path, alternate.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatScriptExplainsSessionAndRuntimeFailures(t *testing.T) {
|
||||
body, err := staticFiles.ReadFile("static/assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(body)
|
||||
for _, required := range []string{"class ChatApiError", "Сессия устарела", "основной runtime сейчас недоступен", "Проверьте текст и повторите попытку"} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Fatalf("chat script is missing recovery message %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSurfacesSeparateChatAndConsole(t *testing.T) {
|
||||
handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
must string
|
||||
mustNot string
|
||||
}{
|
||||
{path: "/", must: "Напишите Домовому обычное сообщение", mustNot: "context-preset"},
|
||||
{path: "/admin", must: "Чат с Домовым", mustNot: "Напишите Домовому обычное сообщение"},
|
||||
{path: "/chat", must: "Напишите Домовому обычное сообщение", mustNot: "context-preset"},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
body := recorder.Body.String()
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(body, test.must) || strings.Contains(body, test.mustNot) {
|
||||
t.Fatalf("%s response=%d contains expected=%t forbidden=%t", test.path, recorder.Code, strings.Contains(body, test.must), strings.Contains(body, test.mustNot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
@@ -46,7 +100,7 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
t.Fatalf("unexpected core chat request: %#v", request)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, InterpretationState: interaction.InterpretationInterpreted, NeedsCognition: true, FallbackText: "fallback"})
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, InterpretationState: interaction.InterpretationInterpreted, NeedsCognition: true, FallbackText: "fallback", InformationRequestKind: interaction.InformationRequestCognitiveChannelStatus})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
@@ -92,4 +146,82 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
if cognitiveRequest.ActorID != "actor-1" || cognitiveRequest.PrincipalID != "principal-1" || cognitiveRequest.ScopeID != "domovoy-global-scope" || cognitiveRequest.Message != "Кто ты?" {
|
||||
t.Fatalf("unexpected trusted gateway request: %#v", cognitiveRequest)
|
||||
}
|
||||
if cognitiveRequest.Meaning.InformationRequestKind != interaction.InformationRequestCognitiveChannelStatus {
|
||||
t.Fatalf("information intent was not preserved: %#v", cognitiveRequest.Meaning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActDirective, FallbackText: "fallback", PlanningReply: "Готово. Я создал напоминание: позвонить врачу.", CapabilityStatus: capability.ResolutionAvailable, CapabilityKind: capability.CapabilityKindScheduleReminder})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Напомни завтра в 10 позвонить врачу."}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ReplyText != "Готово. Я создал напоминание: позвонить врачу." || response.CapabilityStatus != string(capability.ResolutionAvailable) || response.CapabilityKind != string(capability.CapabilityKindScheduleReminder) {
|
||||
t.Fatalf("unexpected browser response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatAttentionQuestionUsesContinuityToday(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, FallbackText: "Сейчас в фокусе: УПО."})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
continuityRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/creator/today" {
|
||||
t.Fatalf("unexpected continuity route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(continuity.TodayResult{Items: []continuity.TodayItem{{Title: "проверить уведомление", HumanState: "доставлено", DeliveryState: "уведомление отправлено"}}})
|
||||
}))
|
||||
defer continuityRuntime.Close()
|
||||
handler, err := newHandler(core.URL, continuityRuntime.URL, "http://commit", core.Client(), "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Что сейчас требует моего внимания?"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(response.ReplyText, "проверить уведомление") || strings.Contains(response.ReplyText, "УПО") {
|
||||
t.Fatalf("attention reply did not use canonical today: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func main() {
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
+272
-24
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -8,11 +9,14 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
@@ -29,12 +33,19 @@ const (
|
||||
var staticFiles embed.FS
|
||||
|
||||
type gateway struct {
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
agentWorker *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
build buildMetadata
|
||||
}
|
||||
|
||||
type buildMetadata struct {
|
||||
revision string
|
||||
updated time.Time
|
||||
}
|
||||
|
||||
type routeTarget uint8
|
||||
@@ -66,6 +77,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentWorkerURL := "http://agent-worker:8090"
|
||||
if len(cognitiveURLs) > 1 {
|
||||
agentWorkerURL = cognitiveURLs[1]
|
||||
}
|
||||
agentWorker, err := parseRuntimeURL(agentWorkerURL, "Agent worker")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load embedded console: %w", err)
|
||||
@@ -73,7 +92,20 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil
|
||||
updated := time.Now().UTC()
|
||||
if raw := strings.TrimSpace(os.Getenv("ASSISTANT_BUILD_TIME")); raw != "" {
|
||||
if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil {
|
||||
updated = parsed.UTC()
|
||||
}
|
||||
}
|
||||
revision := strings.TrimSpace(os.Getenv("ASSISTANT_BUILD_REVISION"))
|
||||
if revision == "" {
|
||||
revision = "development"
|
||||
}
|
||||
if len(revision) > 12 {
|
||||
revision = revision[:12]
|
||||
}
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, agentWorker: agentWorker, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -103,6 +135,14 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleChatMessage(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/fleet/readiness" {
|
||||
g.handleFleetReadiness(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/agent-worker/status" {
|
||||
g.handleAgentWorkerStatus(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -110,17 +150,31 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.URL.Path == "/" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
|
||||
g.serveEmbeddedHTML(w, "chat.html")
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/chat" {
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/chat.html"
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.URL.Path == "/admin" {
|
||||
g.serveEmbeddedHTML(w, "index.html")
|
||||
return
|
||||
}
|
||||
g.static.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (g *gateway) serveEmbeddedHTML(w http.ResponseWriter, name string) {
|
||||
body, err := staticFiles.ReadFile("static/" + name)
|
||||
if err != nil {
|
||||
http.Error(w, "page unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_REVISION__"), []byte(g.build.revision))
|
||||
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_TIME__"), []byte(g.build.updated.Format("02.01.2006 15:04 UTC")))
|
||||
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_DATETIME__"), []byte(g.build.updated.Format(time.RFC3339)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
type browserAdvisoryRequest struct {
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
@@ -142,14 +196,23 @@ type browserChatRequest struct {
|
||||
}
|
||||
|
||||
type browserChatResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
UtteranceID string `json:"utterance_id"`
|
||||
SpeechAct string `json:"speech_act"`
|
||||
ReplyText string `json:"reply_text"`
|
||||
Cognition string `json:"cognition"`
|
||||
ActiveFocus string `json:"active_focus"`
|
||||
ProjectProvenance string `json:"project_provenance"`
|
||||
Clarification string `json:"clarification"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
UtteranceID string `json:"utterance_id"`
|
||||
SpeechAct string `json:"speech_act"`
|
||||
ReplyText string `json:"reply_text"`
|
||||
Cognition string `json:"cognition"`
|
||||
ActiveFocus string `json:"active_focus"`
|
||||
ProjectProvenance string `json:"project_provenance"`
|
||||
Clarification string `json:"clarification"`
|
||||
WorkPlanned bool `json:"work_planned"`
|
||||
CapabilityStatus string `json:"capability_status"`
|
||||
CapabilityKind string `json:"capability_kind"`
|
||||
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
|
||||
RequestedOutcome string `json:"requested_outcome,omitempty"`
|
||||
InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"`
|
||||
DelegationID string `json:"delegation_id"`
|
||||
DelegationState string `json:"delegation_state"`
|
||||
ReminderScheduled bool `json:"reminder_scheduled"`
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,6 +265,82 @@ func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(policy)
|
||||
}
|
||||
|
||||
func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
|
||||
if status == 0 {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
target := *g.cognitive
|
||||
target.Path = "/v1/fleet/readiness"
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAgentWorkerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
|
||||
if status == 0 {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
target := *g.agentWorker
|
||||
target.Path = "/status"
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
var browserRequest browserAdvisoryRequest
|
||||
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
|
||||
@@ -316,11 +455,23 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
reply := saved.FallbackText
|
||||
cognition := "not_requested"
|
||||
if saved.NeedsCognition && saved.Clarification != interaction.ClarificationRequired {
|
||||
if saved.PlanningReply != "" {
|
||||
reply = saved.PlanningReply
|
||||
}
|
||||
if asksAttention(browserRequest.Message) {
|
||||
var today continuity.TodayResult
|
||||
status, err = g.getTrustedJSON(r, g.continuity, "/v1/creator/today", "", authorization, &today)
|
||||
if err == nil && status == http.StatusOK {
|
||||
reply = renderTodayReply(today)
|
||||
cognition = "not_requested"
|
||||
saved.NeedsCognition = false
|
||||
}
|
||||
}
|
||||
if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
||||
cognition = "unavailable"
|
||||
requestID, idErr := ids.New()
|
||||
if idErr == nil {
|
||||
trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false}
|
||||
trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: localChatContext(saved), Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false, Meaning: cognitive.TaskMeaning{SpeechAct: string(saved.SpeechAct), CapabilityKind: saved.CapabilityKind, CapabilityHints: saved.CapabilityHints, RequestedOutcome: saved.RequestedOutcome, InformationRequestKind: saved.InformationRequestKind, ConfidenceMillis: saved.InterpretationConfidenceMillis}}
|
||||
payload, err = json.Marshal(trusted)
|
||||
if err == nil {
|
||||
var advisory cognitive.GatewayAdvisoryResponse
|
||||
@@ -336,7 +487,88 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(browserChatResponse{ConversationID: string(saved.ConversationID), UtteranceID: string(saved.UtteranceID), SpeechAct: string(saved.SpeechAct), ReplyText: reply, Cognition: cognition, ActiveFocus: saved.ActiveProjectName, ProjectProvenance: string(saved.ProjectProvenance), Clarification: string(saved.Clarification)})
|
||||
_ = json.NewEncoder(w).Encode(browserChatResponse{
|
||||
ConversationID: string(saved.ConversationID),
|
||||
UtteranceID: string(saved.UtteranceID),
|
||||
SpeechAct: string(saved.SpeechAct),
|
||||
ReplyText: reply,
|
||||
Cognition: cognition,
|
||||
ActiveFocus: saved.ActiveProjectName,
|
||||
ProjectProvenance: string(saved.ProjectProvenance),
|
||||
Clarification: string(saved.Clarification),
|
||||
WorkPlanned: saved.WorkPlanned,
|
||||
CapabilityStatus: string(saved.CapabilityStatus),
|
||||
CapabilityKind: string(saved.CapabilityKind),
|
||||
CapabilityHints: saved.CapabilityHints,
|
||||
RequestedOutcome: saved.RequestedOutcome,
|
||||
InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis,
|
||||
DelegationID: saved.DelegationID,
|
||||
DelegationState: saved.DelegationState,
|
||||
ReminderScheduled: saved.Reminder != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
|
||||
if strings.TrimSpace(saved.ActiveProjectName) == "" {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
MaxBytes: 2048,
|
||||
Candidates: []cognitive.ContextCandidate{{
|
||||
Excerpt: cognitive.ContextExcerpt{
|
||||
ID: cognitive.ContextExcerptID("active-project-focus"),
|
||||
SourceKind: "situation-active-project",
|
||||
SourceID: strings.TrimSpace(saved.ActiveProjectName),
|
||||
Sensitivity: cognitive.SensitivityPrivateAllowed,
|
||||
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
|
||||
},
|
||||
Relevance: 100,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
}
|
||||
return bundle.Excerpts
|
||||
}
|
||||
|
||||
func asksAttention(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "требует моего внимания") || strings.Contains(lower, "требует внимания") || strings.Contains(lower, "что сейчас важно")
|
||||
}
|
||||
|
||||
func renderTodayReply(today continuity.TodayResult) string {
|
||||
if len(today.Items) == 0 {
|
||||
return "Сейчас ничего срочного не требует внимания."
|
||||
}
|
||||
limit := len(today.Items)
|
||||
if limit > 5 {
|
||||
limit = 5
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Сейчас требует внимания:\n")
|
||||
for index := 0; index < limit; index++ {
|
||||
item := today.Items[index]
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
title = "событие Домового"
|
||||
}
|
||||
state := strings.TrimSpace(item.HumanState)
|
||||
if state == "" {
|
||||
state = "ожидает внимания"
|
||||
}
|
||||
builder.WriteString("- ")
|
||||
builder.WriteString(title)
|
||||
builder.WriteString(" — ")
|
||||
builder.WriteString(state)
|
||||
if item.DeliveryState != "" {
|
||||
builder.WriteString(" (")
|
||||
builder.WriteString(item.DeliveryState)
|
||||
builder.WriteString(")")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool {
|
||||
@@ -480,6 +712,18 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
if validID(id) {
|
||||
return targetCore, "/v1/creator/capabilities/" + id + "/health", true
|
||||
}
|
||||
case method == http.MethodGet && path == "/api/learning/recent":
|
||||
return targetCore, "/v1/creator/learning/recent", true
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/") && strings.HasSuffix(path, "/latest-checkpoint"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/delegations/"), "/latest-checkpoint")
|
||||
if validID(id) {
|
||||
return targetCore, "/v1/creator/delegations/" + id + "/latest-checkpoint", true
|
||||
}
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/"):
|
||||
id := strings.TrimPrefix(path, "/api/delegations/")
|
||||
if validID(id) {
|
||||
return targetCore, "/v1/creator/delegations/" + id, true
|
||||
}
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/prospective-items/"):
|
||||
id := strings.TrimPrefix(path, "/api/prospective-items/")
|
||||
if validID(id) {
|
||||
@@ -487,6 +731,10 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
}
|
||||
case method == http.MethodGet && path == "/api/prospective-items":
|
||||
return targetContinuity, "/v1/creator/prospective-items", true
|
||||
case method == http.MethodGet && path == "/api/attention":
|
||||
return targetContinuity, "/v1/creator/attention", true
|
||||
case method == http.MethodGet && path == "/api/today":
|
||||
return targetContinuity, "/v1/creator/today", true
|
||||
case method == http.MethodGet && path == "/api/actions":
|
||||
return targetCommit, "/v1/creator/authorized-actions", true
|
||||
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
|
||||
|
||||
@@ -10,8 +10,105 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
|
||||
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
|
||||
if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" {
|
||||
t.Fatalf("unexpected local context: %#v", context)
|
||||
}
|
||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
|
||||
t.Fatalf("expected empty context without active project: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorRouteAllowsOnlyBoundedDelegationReads(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/delegations/delegation-1", "/v1/creator/delegations/delegation-1"},
|
||||
{"/api/delegations/delegation-1/latest-checkpoint", "/v1/creator/delegations/delegation-1/latest-checkpoint"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
_, got, ok := creatorRoute(http.MethodGet, test.path)
|
||||
if !ok || got != test.want {
|
||||
t.Fatalf("creatorRoute(%q) = %q, %t; want %q, true", test.path, got, ok, test.want)
|
||||
}
|
||||
}
|
||||
if _, _, ok := creatorRoute(http.MethodGet, "/api/delegations/../secrets"); ok {
|
||||
t.Fatal("path traversal-like delegation route must be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWorkerStatusProxyRequiresCreatorSession(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
}))
|
||||
defer core.Close()
|
||||
worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/status" {
|
||||
t.Fatalf("unexpected worker path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"runtime":"agent-worker","executor_configured":false,"mode":"recovery-only","ready":false,"reason":"bridge missing"}`)
|
||||
}))
|
||||
defer worker.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), "http://cognitive.invalid", worker.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil))
|
||||
if denied.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status=%d", denied.Code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode":"recovery-only"`) || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessRequiresCreatorSessionAndProxiesCognitiveState(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/fleet/readiness" {
|
||||
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"readiness":{"members":[{"provider_instance_id":"lifeboat","model_descriptor_id":"qwen","tier":"LIFEBOAT","ready":true,"reason":"AVAILABLE"}],"ready_tiers":["LIFEBOAT"],"fallback_tier":"LIFEBOAT"},"preferred_tier":"HEAVY","selected_tier":"LIFEBOAT"}`)
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil))
|
||||
if denied.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status=%d", denied.Code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"selected_tier":"LIFEBOAT"`) || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
const legacyExternalMode = "legacy-external-test-mode"
|
||||
|
||||
func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, mode string, cognitiveURLs ...string) (http.Handler, error) {
|
||||
@@ -229,10 +326,21 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
|
||||
!strings.Contains(response.Body.String(), "context-preset") ||
|
||||
!strings.Contains(response.Body.String(), "context-state") ||
|
||||
!strings.Contains(response.Body.String(), "copy-context") ||
|
||||
!strings.Contains(response.Body.String(), "К содержимому") ||
|
||||
!strings.Contains(response.Body.String(), "aria-current=\"location\"") ||
|
||||
!strings.Contains(response.Body.String(), "section-nav-link") ||
|
||||
!strings.Contains(response.Body.String(), "data-page=\"overview\"") ||
|
||||
!strings.Contains(response.Body.String(), "data-page=\"diagnostics\"") ||
|
||||
!strings.Contains(response.Body.String(), "data-page=\"operations\"") ||
|
||||
!strings.Contains(response.Body.String(), "data-page=\"learning\"") ||
|
||||
!strings.Contains(response.Body.String(), "data-page=\"settings\"") ||
|
||||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
|
||||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
|
||||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
|
||||
|
||||
@@ -29,22 +29,97 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.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-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; }
|
||||
.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); }
|
||||
.chat-view { 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); }
|
||||
.chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
#active-focus { margin-left: auto; color: var(--good); }
|
||||
#active-focus { color: var(--good); }
|
||||
.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); }
|
||||
.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; }
|
||||
.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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 { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
|
||||
.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); }
|
||||
.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); }
|
||||
.empty-state.error { color: var(--bad); }
|
||||
@media (max-width: 640px) {
|
||||
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.composer button { width: 100%; }
|
||||
.chat-header span { display: none; }
|
||||
.conversation-starters { align-items: stretch; }
|
||||
.conversation-starters span { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.build-meta { font-size: 8px; letter-spacing: .04em; }
|
||||
.build-meta time { font-size: 7px; }
|
||||
.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; }
|
||||
}
|
||||
@media (min-width: 641px) and (max-width: 1100px) {
|
||||
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
|
||||
.chat-side { padding-inline: 14px; }
|
||||
.side-intro h1, .side-intro h2 { font-size: 30px; }
|
||||
}
|
||||
|
||||
@@ -8,9 +8,84 @@ const messageInput = document.querySelector("#message");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const activeFocus = document.querySelector("#active-focus");
|
||||
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");
|
||||
|
||||
let token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||
let localHistory = [];
|
||||
let cognitiveStatusTimer = null;
|
||||
|
||||
class ChatApiError extends Error {
|
||||
constructor(status, detail = "") {
|
||||
super(detail || `chat api returned ${status}`);
|
||||
this.name = "ChatApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function containsSecretShapedText(value) {
|
||||
return /(-----BEGIN [A-Z ]+PRIVATE KEY-----|\b(?:password|passwd|пароль|token|токен|api[_ -]?key|секрет|ключ)\s*[:=]|\bsk-[a-zA-Z0-9_-]{12,})/i.test(value);
|
||||
}
|
||||
|
||||
function showAIDataError(message = "") {
|
||||
if (!aiDataError) return;
|
||||
aiDataError.textContent = message;
|
||||
aiDataError.hidden = !message;
|
||||
}
|
||||
|
||||
function renderCognitiveStatus(state) {
|
||||
if (!cognitiveStatus) return;
|
||||
const normalized = String(state || "UNKNOWN").toUpperCase();
|
||||
let displayState = "degraded";
|
||||
if (normalized === "AVAILABLE") displayState = "available";
|
||||
if (normalized === "UNAVAILABLE") displayState = "unavailable";
|
||||
const labels = {
|
||||
available: "канал мышления доступен",
|
||||
degraded: "канал мышления работает с ограничениями",
|
||||
unavailable: "Домовой рядом · основной канал недоступен",
|
||||
};
|
||||
cognitiveStatus.dataset.state = displayState;
|
||||
cognitiveStatus.textContent = labels[displayState];
|
||||
cognitiveStatus.title = displayState === "available" ? "Канал доступен. Нажмите, чтобы проверить ещё раз" : "Проверить канал ещё раз";
|
||||
if (cognitiveConsoleLink) cognitiveConsoleLink.hidden = displayState === "available";
|
||||
}
|
||||
|
||||
async function loadCognitiveStatus() {
|
||||
if (!cognitiveStatus || !token) return;
|
||||
cognitiveStatus.dataset.state = "checking";
|
||||
cognitiveStatus.textContent = "проверяю канал мышления";
|
||||
try {
|
||||
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;
|
||||
}
|
||||
renderCognitiveStatus("UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
function startCognitiveStatusPolling() {
|
||||
if (cognitiveStatusTimer) return;
|
||||
loadCognitiveStatus();
|
||||
cognitiveStatusTimer = window.setInterval(loadCognitiveStatus, 30000);
|
||||
}
|
||||
|
||||
function stopCognitiveStatusPolling() {
|
||||
if (!cognitiveStatusTimer) return;
|
||||
window.clearInterval(cognitiveStatusTimer);
|
||||
cognitiveStatusTimer = null;
|
||||
}
|
||||
|
||||
function showChat() {
|
||||
loginView.hidden = true;
|
||||
@@ -18,10 +93,12 @@ function showChat() {
|
||||
if (!messages.children.length) {
|
||||
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
|
||||
}
|
||||
startCognitiveStatusPolling();
|
||||
messageInput.focus();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
stopCognitiveStatusPolling();
|
||||
chatView.hidden = true;
|
||||
loginView.hidden = false;
|
||||
}
|
||||
@@ -37,21 +114,189 @@ function appendMessage(kind, text, meta = "") {
|
||||
}
|
||||
messages.appendChild(node);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
localHistory.push({kind, text, meta, at: new Date()});
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function updateActiveFocus(value, provenance = "") {
|
||||
if (!activeFocus) return;
|
||||
activeFocus.textContent = value ? `фокус: ${value}${provenance ? ` · ${provenance}` : ""}` : "фокус: не выбран";
|
||||
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
|
||||
activeFocus.hidden = !value;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = {"Content-Type": "application/json", ...(options.headers || {})};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(path, {...options, headers});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.ok) throw new ChatApiError(response.status, await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function expireSession(message = "") {
|
||||
token = "";
|
||||
conversationID = "";
|
||||
sessionStorage.removeItem("domovoy.chat.token");
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
updateActiveFocus("");
|
||||
if (message) loginError.textContent = message;
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function humanMeta(result) {
|
||||
if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
|
||||
if (result.delegation_id && result.delegation_state) return `Передал в coding-agent: ${humanDelegationState(result.delegation_state)}.`;
|
||||
if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
|
||||
if (result.work_planned) return "Я разложил это как работу.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function humanDelegationState(state) {
|
||||
switch (state) {
|
||||
case "RUNNING":
|
||||
return "работа запущена";
|
||||
case "WAITING":
|
||||
return "ожидаю следующий шаг";
|
||||
case "BLOCKED":
|
||||
return "нужно решение человека";
|
||||
case "VERIFYING":
|
||||
return "результат проверяется";
|
||||
case "COMPLETED":
|
||||
return "работа завершена";
|
||||
case "FAILED":
|
||||
return "run завершился ошибкой";
|
||||
default:
|
||||
return state.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
if (!historyList) return;
|
||||
historyList.replaceChildren();
|
||||
const items = localHistory.slice(-30).reverse();
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "История появится после сообщений в этом окне.";
|
||||
historyList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const row = document.createElement("article");
|
||||
row.className = `history-item ${item.kind}`;
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.kind === "user" ? "Вы" : "Домовой";
|
||||
const body = document.createElement("p");
|
||||
body.textContent = item.text;
|
||||
const time = document.createElement("span");
|
||||
time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
|
||||
row.append(title, body, time);
|
||||
historyList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadToday() {
|
||||
if (!todayList || !token) return;
|
||||
todayList.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "empty-state";
|
||||
loading.textContent = "Смотрю, что требует внимания...";
|
||||
todayList.appendChild(loading);
|
||||
try {
|
||||
const result = await api("/api/today", {method: "GET"});
|
||||
todayList.replaceChildren();
|
||||
const items = result.Items || result.items || [];
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "Сейчас ничего срочного не требует внимания.";
|
||||
todayList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "attention-card";
|
||||
const when = document.createElement("time");
|
||||
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
|
||||
when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = item.Title || item.title || "Событие Домового";
|
||||
const state = document.createElement("p");
|
||||
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
|
||||
const source = document.createElement("small");
|
||||
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
|
||||
const hintText = item.ResponseHint || item.response_hint || "";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "attention-actions";
|
||||
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", async () => {
|
||||
await sendChatMessage(text, {silentUser: false});
|
||||
await loadToday();
|
||||
activateTab("chat-panel");
|
||||
});
|
||||
actions.appendChild(button);
|
||||
}
|
||||
if (hintText) {
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = hintText;
|
||||
card.append(when, title, state, source, hint, actions);
|
||||
} else {
|
||||
card.append(when, title, state, source, actions);
|
||||
}
|
||||
todayList.appendChild(card);
|
||||
}
|
||||
} catch {
|
||||
todayList.replaceChildren();
|
||||
const error = document.createElement("p");
|
||||
error.className = "empty-state error";
|
||||
error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
|
||||
todayList.appendChild(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChatMessage(text, options = {}) {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
if (!options.silentUser) appendMessage("user", clean);
|
||||
sendButton.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
|
||||
if (/что сейчас требует моего внимания/i.test(clean)) {
|
||||
await loadToday();
|
||||
activateTab("today-panel");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
|
||||
} else if (error instanceof ChatApiError && error.status >= 500) {
|
||||
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
|
||||
} else {
|
||||
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
|
||||
}
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
messageInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(panelID) {
|
||||
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
|
||||
for (const panel of document.querySelectorAll(".surface-panel")) {
|
||||
const active = panel.id === panelID;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("active-panel", active);
|
||||
}
|
||||
if (panelID === "today-panel") loadToday();
|
||||
if (panelID === "history-panel") renderHistory();
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
loginError.textContent = "";
|
||||
@@ -71,33 +316,43 @@ chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
appendMessage("user", text);
|
||||
messageInput.value = "";
|
||||
sendButton.disabled = true;
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: text})});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
const semanticMeta = [result.speech_act, `cognition=${result.cognition}`];
|
||||
if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`);
|
||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; "));
|
||||
} catch {
|
||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
messageInput.focus();
|
||||
}
|
||||
await sendChatMessage(text);
|
||||
});
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
}
|
||||
|
||||
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 (refreshToday) refreshToday.addEventListener("click", loadToday);
|
||||
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
|
||||
if (aiDataForm) {
|
||||
aiDataForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const details = aiDataInput.value.trim();
|
||||
if (!details || sendButton.disabled) return;
|
||||
if (containsSecretShapedText(details)) {
|
||||
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
|
||||
return;
|
||||
}
|
||||
showAIDataError();
|
||||
aiDataInput.value = "";
|
||||
await sendChatMessage(`Передаю сведения о локальном ИИ для текущего разговора:\n${details}`);
|
||||
});
|
||||
}
|
||||
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
token = "";
|
||||
conversationID = "";
|
||||
sessionStorage.removeItem("domovoy.chat.token");
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
updateActiveFocus("");
|
||||
showLogin();
|
||||
expireSession();
|
||||
});
|
||||
|
||||
if (token) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
:root {
|
||||
--ink: #e9eee7;
|
||||
--muted: #8c9a91;
|
||||
--faint: #58645d;
|
||||
--base: #0b100e;
|
||||
--panel: #101714;
|
||||
--panel-high: #151e1a;
|
||||
--line: #2a352f;
|
||||
--line-soft: #1d2822;
|
||||
--ink: #eef2ec;
|
||||
--muted: #a7b2aa;
|
||||
--faint: #718078;
|
||||
--base: #0f1512;
|
||||
--panel: #17201b;
|
||||
--panel-high: #202b25;
|
||||
--line: #34423a;
|
||||
--line-soft: #26332c;
|
||||
--signal: #c9a85f;
|
||||
--signal-bright: #e7c97b;
|
||||
--good: #78c6a3;
|
||||
@@ -21,13 +21,15 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html { background: var(--base); color: var(--ink); font-family: var(--sans); }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .15), transparent 38%), var(--base); }
|
||||
html { background: var(--base); color: var(--ink); font-family: var(--sans); scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .13), transparent 38%), var(--base); }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
|
||||
.skip-link { position: fixed; top: 10px; left: 10px; z-index: 30; padding: 10px 13px; color: #11150f; background: var(--signal-bright); font: 600 10px/1 var(--mono); text-decoration: none; transform: translateY(-160%); transition: transform .18s; }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.grain { position: fixed; inset: 0; pointer-events: none; opacity: .18; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); }
|
||||
.grain { position: fixed; inset: 0; pointer-events: none; opacity: .045; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); }
|
||||
.eyebrow, .rail-index { margin: 0 0 12px; color: var(--signal); font: 600 11px/1 var(--mono); letter-spacing: .18em; text-transform: uppercase; }
|
||||
|
||||
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; position: relative; overflow: hidden; }
|
||||
@@ -63,6 +65,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.wordmark strong { font: 400 20px/1 var(--serif); }
|
||||
.wordmark small { margin-top: 4px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.chat-return-link { color: var(--signal); text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
|
||||
.topbar-nav { display: flex; align-items: center; gap: 4px; margin-right: 8px; }
|
||||
.topbar-nav a { padding: 9px 10px; color: var(--muted); text-decoration: none; font: 600 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.topbar-nav a:hover, .topbar-nav a:focus-visible { color: var(--ink); background: var(--panel-high); outline: 0; }
|
||||
.connection { margin-right: 12px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.connection i { width: 7px; height: 7px; display: inline-block; margin-right: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 14px rgba(120, 198, 163, .6); }
|
||||
.connection.offline i { background: var(--bad); box-shadow: none; }
|
||||
@@ -73,17 +79,55 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.context-rail h2 { margin: 0; font: 400 29px/1.1 var(--serif); }
|
||||
.rail-copy { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.context-form { display: grid; }
|
||||
.context-status-row { margin-bottom: 16px; padding: 10px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line-soft); background: rgba(120,198,163,.04); }
|
||||
.context-status-label { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; }
|
||||
.context-status-row strong { color: var(--warn); font: 600 9px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.context-status-row strong[data-state="ready"] { color: var(--good); }
|
||||
.context-presets { display: grid; gap: 7px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); }
|
||||
.context-presets label { margin: 0; }
|
||||
.context-presets select { width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 10px/1 var(--mono); }
|
||||
.context-presets small { color: var(--faint); font-size: 10px; line-height: 1.35; }
|
||||
.context-field { display: grid; gap: 7px; }
|
||||
.context-field + .context-field { margin-top: 13px; }
|
||||
.context-field label { margin: 0; }
|
||||
.context-field label span { color: var(--signal); font-size: 8px; letter-spacing: .06em; }
|
||||
.context-input-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 5px; }
|
||||
.context-input-row input { min-width: 0; }
|
||||
.copy-context { width: 34px; height: 40px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; }
|
||||
.copy-context:hover { color: var(--ink); border-color: var(--faint); background: var(--panel-high); }
|
||||
.id-actions { min-height: 31px; display: flex; align-items: center; gap: 7px; margin-top: 12px; }
|
||||
.id-action { min-height: 30px; padding: 0 9px; border: 1px solid var(--line-soft); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; font: 9px/1 var(--mono); letter-spacing: .04em; }
|
||||
.id-action:hover { color: var(--ink); border-color: var(--signal); background: rgba(201,168,95,.07); }
|
||||
.icon-id-action { width: 30px; padding: 0; color: var(--faint); }
|
||||
.context-form label { margin-top: 15px; }
|
||||
.context-form label:first-child { margin-top: 0; }
|
||||
.context-form input { height: 40px; padding: 0 10px; font: 11px/1 var(--mono); }
|
||||
.context-hint { margin: 12px 0 0; color: var(--faint); font-size: 10px; line-height: 1.45; }
|
||||
.advanced-context { margin-top: 17px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
|
||||
.advanced-context summary { padding: 12px 0; color: var(--muted); cursor: pointer; list-style: none; font: 600 9px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.advanced-context summary::before { content: "+"; display: inline-block; width: 18px; color: var(--signal); font-size: 13px; }
|
||||
.advanced-context[open] summary::before { content: "−"; }
|
||||
.advanced-context > div { padding: 0 0 10px; }
|
||||
.advanced-context label { margin-top: 13px; }
|
||||
.context-actions { display: grid; gap: 8px; }
|
||||
.context-actions .secondary-button { margin-top: 18px; }
|
||||
.context-actions .text-button { margin: 0; justify-self: start; padding-inline: 0; }
|
||||
.secondary-button { height: 42px; margin-top: 20px; color: #11150f; background: var(--signal); font-size: 12px; }
|
||||
.section-nav { display: grid; gap: 3px; margin-top: 4px; }
|
||||
.section-nav-link { display: grid; grid-template-columns: 28px 1fr; gap: 2px 10px; padding: 11px 10px; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; transition: background .18s, border-color .18s, color .18s; }
|
||||
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="location"] { border-left-color: var(--signal); color: var(--ink); background: rgba(201,168,95,.07); outline: 0; }
|
||||
.section-nav-link span { grid-row: 1 / span 2; color: var(--signal); font: 9px/1.4 var(--mono); }
|
||||
.section-nav-link b { font: 600 11px/1.2 var(--sans); }
|
||||
.section-nav-link small { color: var(--faint); font: 9px/1.2 var(--mono); }
|
||||
.session-stamp { margin-top: auto; padding-top: 20px; display: grid; gap: 6px; border-top: 1px solid var(--line); }
|
||||
.session-stamp span { margin-top: 8px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; }
|
||||
.session-stamp strong { overflow: hidden; color: var(--muted); font: 10px/1.4 var(--mono); text-overflow: ellipsis; }
|
||||
|
||||
.workspace { grid-column: 2; grid-row: 2; padding: clamp(28px, 4vw, 58px); min-width: 0; }
|
||||
.workspace { grid-column: 2; grid-row: 2; width: min(100%, 1740px); justify-self: start; padding: 42px clamp(24px, 3vw, 48px) 64px; min-width: 0; }
|
||||
.workspace > [data-page][hidden] { display: none !important; }
|
||||
.workspace > section { scroll-margin-top: 94px; }
|
||||
.workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
|
||||
.workspace-head h1 { margin: 0; font: 400 clamp(36px, 5vw, 58px)/1 var(--serif); letter-spacing: -.035em; }
|
||||
.workspace-head h1 { margin: 0; font: 400 clamp(34px, 4vw, 50px)/1 var(--serif); letter-spacing: -.035em; }
|
||||
.last-update { color: var(--faint); font: 10px/1.4 var(--mono); text-align: right; }
|
||||
.signal-strip { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.signal-strip div { padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-right: 1px solid var(--line); }
|
||||
@@ -94,6 +138,46 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.signal-strip strong[data-state="error"] { color: var(--bad); }
|
||||
.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||
|
||||
.diagnostics-panel, .settings-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16,23,20,.82); }
|
||||
.diagnostics-panel { border-color: rgba(120,198,163,.32); box-shadow: inset 3px 0 0 rgba(120,198,163,.45); }
|
||||
.panel-heading { min-height: 68px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.panel-heading p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.panel-heading h2 { margin: 0; font: 400 23px/1 var(--serif); }
|
||||
.panel-heading-tools { display: flex; align-items: center; gap: 14px; }
|
||||
.panel-heading-tools > span, .settings-saved { color: var(--muted); font: 9px/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.compact-button { width: auto; min-width: 142px; height: 38px; margin: 0; padding: 0 13px; }
|
||||
.diagnostic-intro { padding: 13px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line-soft); color: var(--muted); }
|
||||
.diagnostic-intro p { margin: 0; font-size: 11px; line-height: 1.45; }
|
||||
.diagnostic-pulse { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--good); box-shadow: 0 0 12px rgba(120,198,163,.7); }
|
||||
.diagnostic-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.diagnostic-list[aria-busy="true"] { opacity: .7; }
|
||||
.diagnostic-item { min-width: 0; min-height: 92px; padding: 14px 16px; display: grid; grid-template-columns: 9px 1fr auto; gap: 9px; align-items: start; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
|
||||
.diagnostic-item:nth-child(4n) { border-right: 0; }
|
||||
.diagnostic-item > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--faint); }
|
||||
.diagnostic-item[data-state="ok"] > i { background: var(--good); box-shadow: 0 0 9px rgba(120,198,163,.6); }
|
||||
.diagnostic-item[data-state="error"] > i { background: var(--bad); }
|
||||
.diagnostic-item[data-state="loading"] > i { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||
.diagnostic-item strong { display: block; color: var(--ink); font: 600 10px/1.3 var(--mono); }
|
||||
.diagnostic-item span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.35; }
|
||||
.diagnostic-item em { color: var(--faint); font: normal 9px/1.2 var(--mono); text-transform: uppercase; }
|
||||
.diagnostic-item[data-state="ok"] em { color: var(--good); }
|
||||
.diagnostic-item[data-state="error"] em { color: var(--bad); }
|
||||
.settings-grid { padding: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
|
||||
.setting-field, .setting-check { min-width: 0; display: grid; gap: 7px; color: var(--ink); }
|
||||
.setting-field > span, .setting-check b { font: 600 10px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.setting-field small, .setting-check small { color: var(--muted); font-size: 11px; line-height: 1.4; }
|
||||
.setting-field select { width: 100%; min-height: 40px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 11px/1 var(--mono); }
|
||||
.setting-check { grid-template-columns: 18px 1fr; align-items: start; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-soft); }
|
||||
.setting-check input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--signal); }
|
||||
.setting-check span { display: grid; gap: 7px; }
|
||||
.settings-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; grid-column: 1 / -1; padding-top: 2px; }
|
||||
.settings-actions .text-button { margin: 0; }
|
||||
html[data-density="compact"] .data-card { padding: 18px; }
|
||||
html[data-density="compact"] .data-card dl div { padding-block: 10px; }
|
||||
html[data-density="compact"] .activity-row { min-height: 58px; padding-block: 8px; }
|
||||
html[data-density="compact"] .learning-row { min-height: 54px; padding-block: 9px; }
|
||||
html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, html[data-reduced-motion="true"] *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
|
||||
.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); }
|
||||
.advisor-intro { align-self: center; }
|
||||
.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; }
|
||||
@@ -141,9 +225,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.register-empty { margin: 0; padding: 22px 18px; color: var(--faint); font-size: 11px; }
|
||||
|
||||
.activity-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(13, 20, 17, .9); }
|
||||
.learning-panel { margin-bottom: 18px; border: 1px solid rgba(120,198,163,.3); background: linear-gradient(135deg, rgba(22,31,27,.92), rgba(10,16,14,.94)); box-shadow: inset 3px 0 0 rgba(120,198,163,.38); }
|
||||
.activity-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.activity-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.activity-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.learning-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.activity-panel header p, .learning-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.activity-panel h2, .learning-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.activity-tools { display: flex; align-items: center; gap: 12px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .1em; }
|
||||
.activity-tools .icon-button { color: var(--muted); font-size: 18px; }
|
||||
.activity-list { max-height: 300px; overflow: auto; scrollbar-color: var(--faint) transparent; }
|
||||
@@ -167,6 +253,57 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.activity-reconcile:disabled { opacity: .55; cursor: wait; }
|
||||
.activity-error { color: var(--bad); }
|
||||
|
||||
.fleet-panel { margin-bottom: 18px; border: 1px solid rgba(201,168,95,.32); background: linear-gradient(135deg, rgba(31,27,18,.92), rgba(13,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.45); }
|
||||
.fleet-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.fleet-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.fleet-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.fleet-overview { padding: 16px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line-soft); }
|
||||
.fleet-overview span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.fleet-overview strong { display: block; margin-top: 6px; color: var(--signal); font: 600 16px/1 var(--mono); }
|
||||
.fleet-overview p { max-width: 620px; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
|
||||
.fleet-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.fleet-list[aria-busy="true"] { opacity: .72; }
|
||||
.fleet-row { min-width: 0; padding: 14px 18px; display: grid; gap: 7px; border-right: 1px solid var(--line-soft); }
|
||||
.fleet-row:last-child { border-right: 0; }
|
||||
.fleet-row strong, .fleet-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fleet-row strong { color: var(--ink); font: 600 11px/1.3 var(--mono); }
|
||||
.fleet-row span { color: var(--muted); font: 9px/1.4 var(--mono); }
|
||||
.fleet-row .learning-state[data-state="READY"] { color: var(--good); }
|
||||
.fleet-row .learning-state[data-state="DEGRADED"] { color: var(--warn); }
|
||||
|
||||
.agent-panel { margin-bottom: 18px; border: 1px solid rgba(120,198,163,.3); background: rgba(13,20,17,.92); box-shadow: inset 3px 0 0 rgba(120,198,163,.38); }
|
||||
.agent-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.agent-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.agent-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.agent-empty { padding: 20px 18px; color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||
.agent-details { padding: 16px 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||
.agent-details div { min-width: 0; }
|
||||
.agent-details span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-details strong { display: block; margin-top: 7px; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-details p { grid-column: 1 / -1; margin: 0; padding-top: 12px; border-top: 1px solid var(--line-soft); color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||
.agent-worker-status { margin: 0 18px 16px; padding-top: 13px; display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; border-top: 1px solid var(--line-soft); }
|
||||
.agent-worker-status span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-worker-status strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
|
||||
.agent-worker-status strong[data-state="ready"] { color: var(--good); }
|
||||
.agent-worker-status p { grid-column: 1 / -1; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
|
||||
|
||||
.learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); }
|
||||
.learning-grid article:last-child { border-right: 0; }
|
||||
.learning-grid h3 { margin: 0; padding: 13px 18px 10px; color: var(--signal); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; border-bottom: 1px solid var(--line-soft); }
|
||||
.learning-list { max-height: 238px; overflow: auto; scrollbar-color: var(--faint) transparent; }
|
||||
.learning-list[aria-busy="true"] { opacity: .72; }
|
||||
.learning-row { min-height: 66px; padding: 12px 18px; display: grid; gap: 6px; border-bottom: 1px solid var(--line-soft); }
|
||||
.learning-row:last-child { border-bottom: 0; }
|
||||
.learning-row:hover { background: rgba(120,198,163,.05); }
|
||||
.learning-row strong { color: var(--ink); font: 600 10px/1.35 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.learning-row span { color: var(--muted); font: 10px/1.35 var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.learning-row time { color: var(--faint); font: 9px/1.35 var(--mono); }
|
||||
.learning-state { width: fit-content; padding: 5px 8px; border: 1px solid currentColor; border-radius: 100px; color: var(--faint); font: 600 8px/1 var(--mono); letter-spacing: .06em; }
|
||||
.learning-state[data-state="SUCCESS"], .learning-state[data-state="VALIDATED"] { color: var(--good); }
|
||||
.learning-state[data-state="USER_CORRECTED"], .learning-state[data-state="FAILED"], .learning-state[data-state="DEGRADED"], .learning-state[data-state="SUSPENDED"] { color: var(--bad); }
|
||||
.learning-state[data-state="CONFLICT"], .learning-state[data-state="OUT_OF_DISTRIBUTION"], .learning-state[data-state="OPEN"], .learning-state[data-state="EVALUATING"] { color: var(--warn); }
|
||||
|
||||
.data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; }
|
||||
.task-card { animation-delay: .08s; }
|
||||
@@ -259,6 +396,9 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.advisor-panel { grid-template-columns: 1fr; }
|
||||
.capability-card { grid-column: auto; }
|
||||
.prospective-card { grid-column: auto; }
|
||||
.diagnostic-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.diagnostic-item:nth-child(4n) { border-right: 1px solid var(--line-soft); }
|
||||
.diagnostic-item:nth-child(2n) { border-right: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
@@ -266,14 +406,26 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.console { display: block; }
|
||||
.topbar { height: 66px; padding: 0 14px; }
|
||||
.wordmark small, .connection, .text-button { display: none; }
|
||||
.chat-return-link { display: inline-block; }
|
||||
.topbar-nav { display: none; }
|
||||
.context-rail { padding: 24px 18px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.rail-copy { max-width: 48ch; }
|
||||
.session-stamp { margin-top: 8px; grid-template-columns: auto 1fr; align-items: center; }
|
||||
.session-stamp span { margin: 0; }
|
||||
.workspace { padding: 30px 16px 50px; }
|
||||
.section-nav { display: flex; gap: 6px; margin: 10px -18px -24px; padding: 10px 18px; overflow-x: auto; border-top: 1px solid var(--line-soft); background: #0e1512; scrollbar-width: thin; }
|
||||
.section-nav-link { min-width: 142px; padding: 10px; border-left: 0; border-bottom: 2px solid transparent; }
|
||||
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="location"] { border-left-color: transparent; border-bottom-color: var(--signal); }
|
||||
.workspace-head { align-items: start; flex-direction: column; }
|
||||
.last-update { text-align: left; }
|
||||
.signal-strip { grid-template-columns: 1fr; }
|
||||
.panel-heading { align-items: flex-start; flex-direction: column; }
|
||||
.panel-heading-tools { width: 100%; justify-content: space-between; }
|
||||
.diagnostic-list { grid-template-columns: 1fr; }
|
||||
.diagnostic-item, .diagnostic-item:nth-child(2n), .diagnostic-item:nth-child(4n) { border-right: 0; }
|
||||
.settings-grid { grid-template-columns: 1fr; padding: 16px; }
|
||||
.settings-actions { grid-column: auto; align-items: stretch; flex-direction: column; }
|
||||
.settings-actions .primary-button { width: 100%; }
|
||||
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.advisor-panel { padding: 20px 16px; gap: 20px; }
|
||||
.advisor-controls { align-items: stretch; flex-direction: column; }
|
||||
@@ -281,7 +433,17 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.advisor-result { grid-template-columns: 1fr; gap: 10px; }
|
||||
.register-panel > header { align-items: start; }
|
||||
.activity-panel > header { align-items: flex-start; }
|
||||
.learning-panel > header { align-items: flex-start; }
|
||||
.activity-tools { align-items: flex-end; flex-direction: column-reverse; }
|
||||
.learning-grid { grid-template-columns: 1fr; }
|
||||
.learning-grid article { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.learning-grid article:last-child { border-bottom: 0; }
|
||||
.fleet-overview { align-items: flex-start; flex-direction: column; }
|
||||
.fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fleet-row:nth-child(2n) { border-right: 0; }
|
||||
.fleet-row { border-bottom: 1px solid var(--line-soft); }
|
||||
.agent-details { grid-template-columns: 1fr; gap: 14px; }
|
||||
.agent-details p { grid-column: auto; }
|
||||
.activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.activity-state { grid-column: 2; grid-row: 1; }
|
||||
.activity-operation { grid-column: 1; grid-row: 1; }
|
||||
|
||||
@@ -6,15 +6,43 @@ const contextForm = byId("context-form");
|
||||
const advisoryForm = byId("advisory-form");
|
||||
const loginError = byId("login-error");
|
||||
const toast = byId("toast");
|
||||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
|
||||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id", "delegation-id"];
|
||||
const settingsStorageKey = "domovoy.creator.settings";
|
||||
const contextPresetsStorageKey = "domovoy.creator.context.presets";
|
||||
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
|
||||
let toastTimer;
|
||||
let refreshTimer;
|
||||
let currentTask = null;
|
||||
let currentProspective = null;
|
||||
let pendingCommand = null;
|
||||
let pendingNotification = null;
|
||||
let lastAuthorizedActionID = "";
|
||||
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
|
||||
let uiSettings = {density: "comfortable", refresh: "off", motion: false};
|
||||
|
||||
const pageNavLinks = Array.from(document.querySelectorAll(".section-nav-link, .topbar-nav a"));
|
||||
const pageViews = Array.from(document.querySelectorAll("[data-page]"));
|
||||
const cabinetPages = ["overview", "diagnostics", "operations", "learning", "settings"];
|
||||
|
||||
function setActivePage(id, updateHistory = false) {
|
||||
const page = cabinetPages.includes(id) ? id : "overview";
|
||||
pageViews.forEach((view) => { view.hidden = view.dataset.page !== page; });
|
||||
document.body.dataset.page = page;
|
||||
pageNavLinks.forEach((link) => {
|
||||
const active = link.getAttribute("href") === `#${page}`;
|
||||
link.classList.toggle("active", active);
|
||||
if (active) link.setAttribute("aria-current", "location");
|
||||
else link.removeAttribute("aria-current");
|
||||
});
|
||||
if (updateHistory && window.location.hash !== `#${page}`) window.history.pushState({}, "", `#${page}`);
|
||||
}
|
||||
|
||||
pageNavLinks.forEach((link) => link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
setActivePage(link.getAttribute("href").slice(1), true);
|
||||
}));
|
||||
window.addEventListener("popstate", () => setActivePage(window.location.hash.slice(1)));
|
||||
setActivePage(window.location.hash.slice(1));
|
||||
|
||||
function setText(id, value, fallback = "—") {
|
||||
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
|
||||
@@ -39,6 +67,18 @@ function notify(message) {
|
||||
toastTimer = setTimeout(() => { toast.hidden = true; }, 4200);
|
||||
}
|
||||
|
||||
function safeClientError(error) {
|
||||
const message = error && typeof error.message === "string" ? error.message : "";
|
||||
switch (message) {
|
||||
case "creator authentication denied":
|
||||
return "Логин или пароль не принят.";
|
||||
case "Сессия отклонена":
|
||||
return "Сессия истекла. Войдите снова.";
|
||||
default:
|
||||
return "Операция недоступна. Попробуйте ещё раз.";
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
|
||||
@@ -50,7 +90,9 @@ async function request(path, options = {}) {
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).trim();
|
||||
throw new Error(detail || `Ошибка ${response.status}`);
|
||||
const error = new Error(detail || `Ошибка ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -64,15 +106,57 @@ function restoreContext() {
|
||||
} catch (_) {
|
||||
localStorage.removeItem("domovoy.creator.context");
|
||||
}
|
||||
renderContextState();
|
||||
renderContextPresets();
|
||||
}
|
||||
|
||||
function saveContext() {
|
||||
const saved = {};
|
||||
contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); });
|
||||
localStorage.setItem("domovoy.creator.context", JSON.stringify(saved));
|
||||
renderContextState();
|
||||
return saved;
|
||||
}
|
||||
|
||||
function readContextPresets() {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(contextPresetsStorageKey) || "[]");
|
||||
return Array.isArray(value) ? value.filter((item) => item && item.session && item.workspace) : [];
|
||||
} catch (_) {
|
||||
localStorage.removeItem(contextPresetsStorageKey);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderContextState() {
|
||||
const state = byId("context-state");
|
||||
if (!state) return;
|
||||
const ready = Boolean(byId("session-id").value.trim() && byId("workspace-id").value.trim());
|
||||
state.textContent = ready ? "готов к проверке" : "не выбран";
|
||||
state.dataset.state = ready ? "ready" : "empty";
|
||||
}
|
||||
|
||||
function renderContextPresets() {
|
||||
const select = byId("context-preset");
|
||||
if (!select) return;
|
||||
const presets = readContextPresets();
|
||||
select.replaceChildren(new Option("Выберите сохранённый набор…", ""));
|
||||
presets.forEach((preset, index) => {
|
||||
const session = preset.session.length > 18 ? `${preset.session.slice(0, 8)}…${preset.session.slice(-6)}` : preset.session;
|
||||
const workspace = preset.workspace.length > 18 ? `${preset.workspace.slice(0, 8)}…${preset.workspace.slice(-6)}` : preset.workspace;
|
||||
select.appendChild(new Option(`${session} · ${workspace}`, String(index)));
|
||||
});
|
||||
}
|
||||
|
||||
function rememberContextPreset(context) {
|
||||
if (!context["session-id"] || !context["workspace-id"]) return;
|
||||
const next = {session: context["session-id"], workspace: context["workspace-id"], savedAt: Date.now()};
|
||||
const presets = readContextPresets().filter((item) => item.session !== next.session || item.workspace !== next.workspace);
|
||||
presets.unshift(next);
|
||||
localStorage.setItem(contextPresetsStorageKey, JSON.stringify(presets.slice(0, 5)));
|
||||
renderContextPresets();
|
||||
}
|
||||
|
||||
async function showConsole() {
|
||||
const session = await request("/api/session/current");
|
||||
setText("session-actor", session.ActorID);
|
||||
@@ -80,15 +164,25 @@ async function showConsole() {
|
||||
setText("session-expiry", expiry);
|
||||
loginView.hidden = true;
|
||||
consoleView.hidden = false;
|
||||
restoreSettings();
|
||||
restoreContext();
|
||||
await loadAdvisoryPolicy();
|
||||
const context = saveContext();
|
||||
if (context["session-id"] && context["workspace-id"]) loadDashboard();
|
||||
else loadActionHistory();
|
||||
else {
|
||||
loadActionHistory();
|
||||
loadLearning();
|
||||
loadFleetReadiness();
|
||||
loadAgentDelegation();
|
||||
loadAgentWorkerStatus();
|
||||
}
|
||||
runDiagnostics();
|
||||
}
|
||||
|
||||
function endSession(message = "Сессия завершена.") {
|
||||
sessionToken = "";
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
sessionStorage.removeItem("domovoy.creator.session");
|
||||
consoleView.hidden = true;
|
||||
loginView.hidden = false;
|
||||
@@ -125,7 +219,7 @@ loginForm.addEventListener("submit", async (event) => {
|
||||
byId("creator-password").value = "";
|
||||
await showConsole();
|
||||
} catch (error) {
|
||||
loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message;
|
||||
loginError.textContent = safeClientError(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.firstElementChild.textContent = "Открыть консоль";
|
||||
@@ -141,13 +235,131 @@ byId("toggle-secret").addEventListener("click", () => {
|
||||
|
||||
contextForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
saveContext();
|
||||
const context = saveContext();
|
||||
rememberContextPreset(context);
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
for (const input of [byId("session-id"), byId("workspace-id")]) {
|
||||
input.addEventListener("input", renderContextState);
|
||||
}
|
||||
|
||||
byId("context-preset").addEventListener("change", (event) => {
|
||||
const index = Number(event.target.value);
|
||||
if (!Number.isInteger(index)) return;
|
||||
const preset = readContextPresets()[index];
|
||||
if (!preset) return;
|
||||
byId("session-id").value = preset.session;
|
||||
byId("workspace-id").value = preset.workspace;
|
||||
renderContextState();
|
||||
loadDashboard();
|
||||
notify("Контекст из недавнего набора загружен.");
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll("[data-copy-context]")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const value = byId(button.dataset.copyContext).value.trim();
|
||||
if (!value) {
|
||||
notify("Сначала укажите идентификатор.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
notify("Идентификатор скопирован.");
|
||||
} catch (_) {
|
||||
notify("Не удалось скопировать идентификатор.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll("[data-copy-value], [data-use-value]")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const source = byId(button.dataset.copyValue || button.dataset.useValue);
|
||||
const value = source?.textContent.trim() || "";
|
||||
if (!value || value === "—") {
|
||||
notify("Сначала загрузите значение.");
|
||||
return;
|
||||
}
|
||||
if (button.dataset.useValue) {
|
||||
const target = byId(button.dataset.targetId);
|
||||
if (!target) return;
|
||||
target.value = value;
|
||||
document.querySelector(".advanced-context").open = true;
|
||||
saveContext();
|
||||
notify("ID подставлен в расширенную проверку.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
notify("ID скопирован.");
|
||||
} catch (_) {
|
||||
notify("Не удалось скопировать ID.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
byId("reset-context").addEventListener("click", () => {
|
||||
contextFields.forEach((id) => { byId(id).value = id === "capability-id" ? "cognitive-proposal" : ""; });
|
||||
localStorage.removeItem("domovoy.creator.context");
|
||||
loadDashboard();
|
||||
notify("Рабочий контекст сброшен.");
|
||||
});
|
||||
|
||||
function restoreSettings() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}");
|
||||
uiSettings = {
|
||||
density: ["comfortable", "compact"].includes(saved.density) ? saved.density : "comfortable",
|
||||
refresh: ["off", "30", "60", "300"].includes(saved.refresh) ? saved.refresh : "off",
|
||||
motion: saved.motion === true,
|
||||
};
|
||||
} catch (_) {
|
||||
uiSettings = {density: "comfortable", refresh: "off", motion: false};
|
||||
}
|
||||
byId("setting-density").value = uiSettings.density;
|
||||
byId("setting-refresh").value = uiSettings.refresh;
|
||||
byId("setting-motion").checked = uiSettings.motion;
|
||||
applySettings();
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
document.documentElement.dataset.density = uiSettings.density;
|
||||
document.documentElement.dataset.reducedMotion = uiSettings.motion ? "true" : "false";
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
if (sessionToken && uiSettings.refresh !== "off") {
|
||||
refreshTimer = setInterval(() => loadDashboard(), Number(uiSettings.refresh) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
byId("settings-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
uiSettings = {
|
||||
density: byId("setting-density").value,
|
||||
refresh: byId("setting-refresh").value,
|
||||
motion: byId("setting-motion").checked,
|
||||
};
|
||||
localStorage.setItem(settingsStorageKey, JSON.stringify(uiSettings));
|
||||
applySettings();
|
||||
setText("settings-saved", "Сохранено только в этом браузере");
|
||||
notify("Настройки кабинета сохранены.");
|
||||
});
|
||||
|
||||
byId("reset-settings").addEventListener("click", () => {
|
||||
uiSettings = {density: "comfortable", refresh: "off", motion: false};
|
||||
localStorage.removeItem(settingsStorageKey);
|
||||
restoreSettings();
|
||||
setText("settings-saved", "Восстановлены значения по умолчанию");
|
||||
notify("Настройки сброшены.");
|
||||
});
|
||||
|
||||
byId("refresh-all").addEventListener("click", loadDashboard);
|
||||
byId("refresh-actions").addEventListener("click", loadActionHistory);
|
||||
byId("refresh-learning").addEventListener("click", loadLearning);
|
||||
byId("refresh-fleet").addEventListener("click", loadFleetReadiness);
|
||||
byId("refresh-agent").addEventListener("click", loadAgentDelegation);
|
||||
byId("logout").addEventListener("click", () => endSession());
|
||||
byId("run-diagnostics").addEventListener("click", runDiagnostics);
|
||||
|
||||
advisoryForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -181,7 +393,7 @@ advisoryForm.addEventListener("submit", async (event) => {
|
||||
byId("advisory-result").hidden = false;
|
||||
} catch (error) {
|
||||
setText("advisory-state", "ОШИБКА");
|
||||
setText("advisory-text", error.message);
|
||||
setText("advisory-text", safeClientError(error));
|
||||
byId("advisory-result").hidden = false;
|
||||
} finally {
|
||||
byId("advisory-egress-ack").checked = false;
|
||||
@@ -234,6 +446,10 @@ async function loadDashboard() {
|
||||
}
|
||||
work.push(loadProspectiveRegister(context["prospective-id"]));
|
||||
work.push(loadActionHistory());
|
||||
work.push(loadLearning());
|
||||
work.push(loadFleetReadiness());
|
||||
work.push(loadAgentDelegation());
|
||||
work.push(loadAgentWorkerStatus());
|
||||
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
||||
else setSignal("capability", "не выбрана", "idle");
|
||||
|
||||
@@ -241,6 +457,244 @@ async function loadDashboard() {
|
||||
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
|
||||
}
|
||||
|
||||
async function runDiagnostics() {
|
||||
const list = byId("diagnostic-list");
|
||||
const summary = byId("diagnostic-summary");
|
||||
const checks = [
|
||||
["Сессия", "identity", () => request("/api/session/current")],
|
||||
["Политика совета", "advisory", () => request("/api/advisory-policy")],
|
||||
["Когнитивная возможность", "capability", () => request("/api/capabilities/cognitive-proposal/health")],
|
||||
["Сегодня", "today", () => request("/api/today")],
|
||||
["Внимание", "attention", () => request("/api/attention")],
|
||||
["Действия", "actions", () => request("/api/actions?limit=1")],
|
||||
["Learning evidence", "learning", () => request("/api/learning/recent?limit=1")],
|
||||
];
|
||||
list.setAttribute("aria-busy", "true");
|
||||
list.replaceChildren();
|
||||
checks.forEach(([label, key]) => list.append(createDiagnosticItem(label, key, "loading")));
|
||||
setText("diagnostic-summary", "Проверяю…");
|
||||
const results = await Promise.all(checks.map(async ([label, key, run]) => {
|
||||
const started = performance.now();
|
||||
try {
|
||||
await run();
|
||||
return {label, key, state: "ok", detail: `Ответ получен · ${Math.round(performance.now() - started)} мс`};
|
||||
} catch (error) {
|
||||
const detail = error?.status === 400 ? "Нужны параметры рабочего контекста" : error?.status === 401 ? "Сессия истекла" : error?.status >= 500 ? "Сервис временно недоступен" : "Проверка не пройдена";
|
||||
return {label, key, state: "error", detail};
|
||||
}
|
||||
}));
|
||||
list.replaceChildren(...results.map((result) => createDiagnosticItem(result.label, result.key, result.state, result.detail)));
|
||||
list.setAttribute("aria-busy", "false");
|
||||
const passed = results.filter((result) => result.state === "ok").length;
|
||||
setText("diagnostic-summary", `${passed}/${results.length} проверок пройдено`);
|
||||
}
|
||||
|
||||
function createDiagnosticItem(label, key, state, detail = "Проверяю…") {
|
||||
const item = document.createElement("article");
|
||||
item.className = "diagnostic-item";
|
||||
item.dataset.state = state;
|
||||
item.dataset.check = key;
|
||||
const marker = document.createElement("i");
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
const copy = document.createElement("div");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = label;
|
||||
const description = document.createElement("span");
|
||||
description.textContent = detail;
|
||||
copy.append(title, description);
|
||||
const status = document.createElement("em");
|
||||
status.textContent = state === "ok" ? "OK" : state === "error" ? "ошибка" : "…";
|
||||
item.append(marker, copy, status);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function loadFleetReadiness() {
|
||||
const list = byId("fleet-list");
|
||||
list.setAttribute("aria-busy", "true");
|
||||
list.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "register-empty";
|
||||
loading.textContent = "Проверяю доступность model fleet…";
|
||||
list.append(loading);
|
||||
try {
|
||||
const result = await request("/api/fleet/readiness");
|
||||
const readiness = result.readiness || {};
|
||||
const members = Array.isArray(readiness.members) ? readiness.members : [];
|
||||
setText("fleet-selected", result.selected_tier || "не выбран");
|
||||
setText("fleet-summary", `${members.filter((member) => member.ready).length}/${members.length} ГОТОВЫ`);
|
||||
setText("fleet-explanation", result.selected_tier ? `Активный маршрут: ${result.selected_tier}. Остальные tier остаются видимыми с их фактической причиной.` : "Ни один tier не выбран: проверьте конфигурацию и health провайдеров.");
|
||||
list.replaceChildren(...members.map(createFleetRow));
|
||||
} catch (error) {
|
||||
setText("fleet-selected", "недоступен");
|
||||
setText("fleet-summary", "ОШИБКА");
|
||||
setText("fleet-explanation", safeClientError(error));
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty activity-error";
|
||||
failed.textContent = safeClientError(error);
|
||||
list.replaceChildren(failed);
|
||||
} finally {
|
||||
list.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function createFleetRow(member) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "fleet-row";
|
||||
const state = document.createElement("em");
|
||||
state.className = "learning-state";
|
||||
state.dataset.state = member.ready ? "READY" : "DEGRADED";
|
||||
state.textContent = member.ready ? "READY" : "DEGRADED";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = member.tier || "UNKNOWN TIER";
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = `${member.model_descriptor_id || "model"} · ${member.reason || "причина не указана"}`;
|
||||
row.append(state, title, detail);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadAgentDelegation() {
|
||||
const delegationID = byId("delegation-id").value.trim();
|
||||
const empty = byId("agent-empty");
|
||||
const details = byId("agent-details");
|
||||
if (!delegationID) {
|
||||
empty.hidden = false;
|
||||
details.hidden = true;
|
||||
setText("agent-summary", "DELEGATION НЕ ВЫБРАНА");
|
||||
return;
|
||||
}
|
||||
empty.hidden = false;
|
||||
empty.textContent = "Читаю durable delegation…";
|
||||
details.hidden = true;
|
||||
try {
|
||||
const delegation = await request(`/api/delegations/${encodeURIComponent(delegationID)}`);
|
||||
const checkpoint = await request(`/api/delegations/${encodeURIComponent(delegationID)}/latest-checkpoint`).catch(() => ({}));
|
||||
setText("agent-summary", delegation.State || "СОСТОЯНИЕ НЕИЗВЕСТНО");
|
||||
setText("agent-delegation-state", delegation.State);
|
||||
setText("agent-run-state", `${delegation.LatestRunID || "run не выбран"} · ${delegation.SelectedImplementationID || "implementation не указана"}`);
|
||||
setText("agent-blocker", checkpoint.BlockerClass || "нет checkpoint");
|
||||
setText("agent-next-step", checkpoint.NextStep || checkpoint.RemainingSummary || delegation.DesiredOutcome || "Следующий шаг не записан.");
|
||||
empty.hidden = true;
|
||||
details.hidden = false;
|
||||
} catch (error) {
|
||||
setText("agent-summary", "ОШИБКА");
|
||||
empty.textContent = safeClientError(error);
|
||||
empty.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentWorkerStatus() {
|
||||
try {
|
||||
const status = await request("/api/agent-worker/status");
|
||||
setText("agent-worker-state", status.mode || "UNKNOWN");
|
||||
setText("agent-worker-message", status.reason || "Причина не указана.");
|
||||
byId("agent-worker-state").dataset.state = status.ready === true ? "ready" : "degraded";
|
||||
} catch (error) {
|
||||
setText("agent-worker-state", "НЕДОСТУПЕН");
|
||||
setText("agent-worker-message", safeClientError(error));
|
||||
byId("agent-worker-state").dataset.state = "degraded";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLearning() {
|
||||
const experienceList = byId("experience-list");
|
||||
const reflectionList = byId("reflection-list");
|
||||
const skillList = byId("skill-list");
|
||||
[experienceList, reflectionList, skillList].forEach((list) => {
|
||||
list.setAttribute("aria-busy", "true");
|
||||
list.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "register-empty";
|
||||
loading.textContent = "Читаю learning evidence…";
|
||||
list.append(loading);
|
||||
});
|
||||
try {
|
||||
const result = await request("/api/learning/recent?limit=8");
|
||||
const experiences = Array.isArray(result.Experiences) ? result.Experiences : [];
|
||||
const reflections = Array.isArray(result.Reflections) ? result.Reflections : [];
|
||||
const skills = Array.isArray(result.Skills) ? result.Skills : [];
|
||||
setText("learning-count", `${experiences.length + reflections.length + skills.length} СИГНАЛОВ`);
|
||||
renderLearningList(experienceList, experiences, createExperienceRow, "Experience пока не записан.");
|
||||
renderLearningList(reflectionList, reflections, createReflectionRow, "ReflectionCandidate пока нет.");
|
||||
renderLearningList(skillList, skills, createSkillRow, "SkillProfile пока не загружен.");
|
||||
} catch (error) {
|
||||
[experienceList, reflectionList, skillList].forEach((list) => {
|
||||
list.replaceChildren();
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty activity-error";
|
||||
failed.textContent = safeClientError(error);
|
||||
list.append(failed);
|
||||
});
|
||||
setText("learning-count", "ОШИБКА");
|
||||
} finally {
|
||||
[experienceList, reflectionList, skillList].forEach((list) => list.setAttribute("aria-busy", "false"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLearningList(list, items, rowFactory, emptyText) {
|
||||
list.replaceChildren();
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "register-empty";
|
||||
empty.textContent = emptyText;
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => list.append(rowFactory(item)));
|
||||
}
|
||||
|
||||
function createExperienceRow(item) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "learning-row";
|
||||
const state = document.createElement("em");
|
||||
state.className = "learning-state";
|
||||
state.dataset.state = item.ActualOutcome || "UNKNOWN";
|
||||
state.textContent = item.ActualOutcome || "UNKNOWN";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.HelpMethod || "help method";
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = item.OutcomeDetail || item.OutcomeRef || "evidence recorded";
|
||||
const time = document.createElement("time");
|
||||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||||
row.append(state, title, detail, time);
|
||||
return row;
|
||||
}
|
||||
|
||||
function createReflectionRow(item) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "learning-row";
|
||||
const state = document.createElement("em");
|
||||
state.className = "learning-state";
|
||||
state.dataset.state = item.State || "OPEN";
|
||||
state.textContent = item.Trigger || "REFLECTION";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.State || "OPEN";
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = item.Summary || "reflection candidate";
|
||||
const time = document.createElement("time");
|
||||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||||
row.append(state, title, detail, time);
|
||||
return row;
|
||||
}
|
||||
|
||||
function createSkillRow(item) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "learning-row";
|
||||
const state = document.createElement("em");
|
||||
state.className = "learning-state";
|
||||
state.dataset.state = item.Status || "UNKNOWN";
|
||||
state.textContent = item.Status || "UNKNOWN";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.Name || "skill profile";
|
||||
const detail = document.createElement("span");
|
||||
const support = Array.isArray(item.SupportingExperienceRefs) ? item.SupportingExperienceRefs.length : 0;
|
||||
const failures = Array.isArray(item.KnownFailureExperienceRefs) ? item.KnownFailureExperienceRefs.length : 0;
|
||||
detail.textContent = `confidence ${item.ConfidenceMillis ?? "—"} · +${support} / -${failures}`;
|
||||
const time = document.createElement("time");
|
||||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||||
row.append(state, title, detail, time);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadActionHistory() {
|
||||
const list = byId("action-history");
|
||||
list.setAttribute("aria-busy", "true");
|
||||
@@ -266,7 +720,7 @@ async function loadActionHistory() {
|
||||
list.replaceChildren();
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty activity-error";
|
||||
failed.textContent = error.message;
|
||||
failed.textContent = safeClientError(error);
|
||||
list.append(failed);
|
||||
setText("action-count", "ОШИБКА");
|
||||
} finally {
|
||||
@@ -325,7 +779,7 @@ async function reconcileHistoryAction(actionID, button) {
|
||||
await loadActionHistory();
|
||||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||||
} catch (error) {
|
||||
notify(error.message);
|
||||
notify(safeClientError(error));
|
||||
button.disabled = false;
|
||||
button.textContent = "Сверить";
|
||||
}
|
||||
@@ -345,7 +799,7 @@ async function loadFocus(query) {
|
||||
} catch (error) {
|
||||
setSignal("focus", "ошибка", "error");
|
||||
setBadge("focus", "НЕДОСТУПЕН", "bad");
|
||||
setText("focus-message", error.message);
|
||||
setText("focus-message", safeClientError(error));
|
||||
byId("connection-state").classList.add("offline");
|
||||
}
|
||||
}
|
||||
@@ -367,7 +821,7 @@ async function loadTask(taskID, query) {
|
||||
currentTask = null;
|
||||
setSignal("task", "отклонена", "error");
|
||||
setBadge("task", "ВНЕ ФОКУСА", "bad");
|
||||
setText("task-message", error.message);
|
||||
setText("task-message", safeClientError(error));
|
||||
updateActionReadiness();
|
||||
}
|
||||
}
|
||||
@@ -406,12 +860,12 @@ async function loadProspectiveRegister(selectedID) {
|
||||
list.replaceChildren();
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty";
|
||||
failed.textContent = error.message;
|
||||
failed.textContent = safeClientError(error);
|
||||
list.append(failed);
|
||||
setText("prospective-count", "ОШИБКА");
|
||||
setSignal("prospective", "ошибка", "error");
|
||||
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
||||
setText("prospective-message", error.message);
|
||||
setText("prospective-message", safeClientError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +921,7 @@ async function loadProspective(prospectiveID) {
|
||||
setSignal("prospective", "ошибка", "error");
|
||||
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
||||
setText("prospective-state", "UNKNOWN");
|
||||
setText("prospective-message", error.message);
|
||||
setText("prospective-message", safeClientError(error));
|
||||
updateActionReadiness();
|
||||
}
|
||||
}
|
||||
@@ -489,7 +943,7 @@ async function loadCapability(capabilityID) {
|
||||
setSignal("capability", "ошибка", "error");
|
||||
setBadge("capability", "НЕДОСТУПНА", "bad");
|
||||
setText("capability-state", "UNKNOWN");
|
||||
setText("capability-reason", error.message);
|
||||
setText("capability-reason", safeClientError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +1049,7 @@ byId("confirm-notification").addEventListener("click", async () => {
|
||||
await loadActionHistory();
|
||||
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
|
||||
} catch (error) {
|
||||
setText("notification-confirmation-error", error.message);
|
||||
setText("notification-confirmation-error", safeClientError(error));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Подтверждаю отправку";
|
||||
@@ -625,7 +1079,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => {
|
||||
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
|
||||
await loadProspectiveRegister(command.ProspectiveItemID);
|
||||
} catch (error) {
|
||||
setText("confirmation-error", error.message);
|
||||
setText("confirmation-error", safeClientError(error));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Подтверждаю переход";
|
||||
@@ -659,7 +1113,7 @@ byId("reconcile-action").addEventListener("click", async () => {
|
||||
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
|
||||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||||
} catch (error) {
|
||||
notify(error.message);
|
||||
notify(safeClientError(error));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Проверить исход";
|
||||
|
||||
@@ -25,16 +25,70 @@
|
||||
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header">
|
||||
<a href="/" aria-label="Консоль создателя">Домовой</a>
|
||||
<span id="active-focus">фокус: не выбран</span>
|
||||
<span id="session-state">сессия активна</span>
|
||||
<a href="/" aria-label="Домовой">Домовой</a>
|
||||
<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="Версия и время обновления">
|
||||
<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>
|
||||
</header>
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
</form>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
|
||||
<div class="side-intro">
|
||||
<p class="side-index">01 / РАЗГОВОР</p>
|
||||
<h1>Домовой рядом</h1>
|
||||
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
|
||||
</div>
|
||||
<div class="side-focus">
|
||||
<span>ТЕКУЩИЙ ФОКУС</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>
|
||||
</nav>
|
||||
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
|
||||
</aside>
|
||||
<main class="chat-main">
|
||||
<section id="chat-panel" class="surface-panel active-panel">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
</form>
|
||||
<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>
|
||||
<div id="today-list" class="attention-list"></div>
|
||||
</section>
|
||||
<section id="history-panel" class="surface-panel history-surface" hidden>
|
||||
<div class="surface-heading"><p>История</p><span>Последние сообщения текущего окна</span></div>
|
||||
<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>
|
||||
<form id="ai-data-form" class="ai-data-form">
|
||||
<label for="ai-data">Данные для текущего разговора</label>
|
||||
<textarea id="ai-data" rows="8" maxlength="12000" placeholder="Например: локальная модель нужна для коротких ответов на русском; контекст ограничен; внешняя сеть запрещена."></textarea>
|
||||
<button type="submit">Передать Домовому</button>
|
||||
<p id="ai-data-error" class="ai-data-error" role="alert" hidden></p>
|
||||
<small>Это отправится как обычное сообщение в чат.</small>
|
||||
</form>
|
||||
<a class="side-console-link" href="/admin">Открыть консоль настройки <span>→</span></a>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/chat.js"></script>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#101714">
|
||||
<title>Домовой — консоль создателя</title>
|
||||
<link rel="stylesheet" href="/assets/console.css">
|
||||
<link rel="stylesheet" href="/assets/console.css?v=20260821-context">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#overview">К содержимому</a>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
|
||||
<main id="login-view" class="login-shell">
|
||||
@@ -43,7 +44,15 @@
|
||||
<span><strong>Домовой</strong><small>консоль создателя</small></span>
|
||||
</a>
|
||||
<div class="topbar-actions">
|
||||
<a class="chat-return-link" href="/">← Чат с Домовым</a>
|
||||
<span id="connection-state" class="connection"><i></i>система доступна</span>
|
||||
<nav class="topbar-nav" aria-label="Разделы кабинета">
|
||||
<a href="#overview">Обзор</a>
|
||||
<a href="#diagnostics">Проверки</a>
|
||||
<a href="#operations">Операции</a>
|
||||
<a href="#learning">Опыт</a>
|
||||
<a href="#settings">Настройки</a>
|
||||
</nav>
|
||||
<button id="refresh-all" class="text-button" type="button">Обновить</button>
|
||||
<button id="logout" class="icon-button" type="button" aria-label="Завершить сессию" title="Выйти">↗</button>
|
||||
</div>
|
||||
@@ -56,18 +65,48 @@
|
||||
<p class="rail-copy">Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.</p>
|
||||
</div>
|
||||
<form id="context-form" class="context-form">
|
||||
<label for="session-id">Идентификатор сессии</label>
|
||||
<input id="session-id" autocomplete="off" placeholder="session-id">
|
||||
<label for="workspace-id">Рабочая область</label>
|
||||
<input id="workspace-id" autocomplete="off" placeholder="workspace-id">
|
||||
<label for="task-id">Задача</label>
|
||||
<input id="task-id" autocomplete="off" placeholder="task-id">
|
||||
<label for="prospective-id">Выбранное ожидание</label>
|
||||
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
|
||||
<label for="capability-id">Возможность</label>
|
||||
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
|
||||
<button class="secondary-button" type="submit">Загрузить контекст</button>
|
||||
<div class="context-status-row">
|
||||
<span class="context-status-label">ТЕКУЩИЙ КОНТЕКСТ</span>
|
||||
<strong id="context-state" data-state="empty">не выбран</strong>
|
||||
</div>
|
||||
<div class="context-presets">
|
||||
<label for="context-preset">Недавние наборы</label>
|
||||
<select id="context-preset">
|
||||
<option value="">Выберите сохранённый набор…</option>
|
||||
</select>
|
||||
<small>Сохраняются только в этом браузере.</small>
|
||||
</div>
|
||||
<div class="context-field">
|
||||
<label for="session-id">Идентификатор сессии <span>обязательно</span></label>
|
||||
<div class="context-input-row"><input id="session-id" autocomplete="off" spellcheck="false" placeholder="Вставьте session-id"><button class="copy-context" data-copy-context="session-id" type="button" aria-label="Скопировать идентификатор сессии" title="Скопировать">⧉</button></div>
|
||||
</div>
|
||||
<div class="context-field">
|
||||
<label for="workspace-id">Рабочая область <span>обязательно</span></label>
|
||||
<div class="context-input-row"><input id="workspace-id" autocomplete="off" spellcheck="false" placeholder="Вставьте workspace-id"><button class="copy-context" data-copy-context="workspace-id" type="button" aria-label="Скопировать рабочую область" title="Скопировать">⧉</button></div>
|
||||
</div>
|
||||
<p class="context-hint">Для «Состояния» нужны только эти два значения. Задача, ожидание и возможность находятся ниже в расширенных полях.</p>
|
||||
<details class="advanced-context">
|
||||
<summary>Расширенные идентификаторы</summary>
|
||||
<div>
|
||||
<label for="task-id">Задача</label>
|
||||
<input id="task-id" autocomplete="off" placeholder="task-id">
|
||||
<label for="prospective-id">Выбранное ожидание</label>
|
||||
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
|
||||
<label for="capability-id">Возможность</label>
|
||||
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
|
||||
<label for="delegation-id">Delegation / агентская работа</label>
|
||||
<input id="delegation-id" autocomplete="off" placeholder="delegation-id из чата">
|
||||
</div>
|
||||
</details>
|
||||
<div class="context-actions"><button class="secondary-button" type="submit">Загрузить контекст</button><button id="reset-context" class="text-button" type="button">Сбросить</button></div>
|
||||
</form>
|
||||
<nav class="section-nav" aria-label="Навигация по кабинету">
|
||||
<a class="section-nav-link active" aria-current="location" href="#overview"><span>01</span><b>Состояние</b><small>фокус и сигналы</small></a>
|
||||
<a class="section-nav-link" href="#diagnostics"><span>02</span><b>Проверки</b><small>доступность и границы</small></a>
|
||||
<a class="section-nav-link" href="#operations"><span>03</span><b>Операции</b><small>ожидания и действия</small></a>
|
||||
<a class="section-nav-link" href="#learning"><span>04</span><b>Опыт</b><small>reflection и навыки</small></a>
|
||||
<a class="section-nav-link" href="#settings"><span>05</span><b>Настройки</b><small>интерфейс и обновление</small></a>
|
||||
</nav>
|
||||
<div class="session-stamp">
|
||||
<span>ACTOR</span>
|
||||
<strong id="session-actor">—</strong>
|
||||
@@ -77,7 +116,7 @@
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="workspace-head">
|
||||
<section id="overview" class="workspace-head" data-page="overview">
|
||||
<div>
|
||||
<p class="eyebrow">операционная картина</p>
|
||||
<h1>Состояние сейчас</h1>
|
||||
@@ -85,14 +124,28 @@
|
||||
<p id="last-update" class="last-update">Данные ещё не запрошены</p>
|
||||
</section>
|
||||
|
||||
<section class="signal-strip" aria-label="Сводка состояния">
|
||||
<section class="signal-strip" aria-label="Сводка состояния" data-page="overview">
|
||||
<div><span>ФОКУС</span><strong id="focus-signal" data-state="idle">ожидает</strong></div>
|
||||
<div><span>ЗАДАЧА</span><strong id="task-signal" data-state="idle">ожидает</strong></div>
|
||||
<div><span>CONTINUITY</span><strong id="prospective-signal" data-state="idle">ожидает</strong></div>
|
||||
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="advisor-panel" aria-labelledby="advisor-title">
|
||||
<section id="diagnostics" class="diagnostics-panel" aria-labelledby="diagnostics-title" data-page="diagnostics">
|
||||
<header class="panel-heading">
|
||||
<div><p>Проверяемая система</p><h2 id="diagnostics-title">Диагностика кабинета</h2></div>
|
||||
<div class="panel-heading-tools"><span id="diagnostic-summary">Проверка ещё не запускалась</span><button id="run-diagnostics" class="secondary-button compact-button" type="button">Проверить всё</button></div>
|
||||
</header>
|
||||
<div class="diagnostic-intro">
|
||||
<span class="diagnostic-pulse" aria-hidden="true"></span>
|
||||
<p>Здесь видны только ответы текущей авторизованной сессии. Проверка не меняет данные и помогает отличить отсутствие контекста от недоступности сервиса.</p>
|
||||
</div>
|
||||
<div id="diagnostic-list" class="diagnostic-list" aria-live="polite" aria-busy="false">
|
||||
<p class="register-empty">Нажмите «Проверить всё», чтобы получить актуальный снимок.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="advisor-panel" aria-labelledby="advisor-title" data-page="diagnostics">
|
||||
<div class="advisor-intro">
|
||||
<p>COGNITIVE / ADVISORY V1</p>
|
||||
<h2 id="advisor-title">Спросить Домового</h2>
|
||||
@@ -120,7 +173,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-panel" aria-labelledby="register-title">
|
||||
<section id="operations" class="register-panel" aria-labelledby="register-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
|
||||
<span id="prospective-count">0 ЗАПИСЕЙ</span>
|
||||
@@ -130,7 +183,31 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title">
|
||||
<section class="fleet-panel" aria-labelledby="fleet-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Cognitive / capability health</p><h2 id="fleet-title">Модели и fallback</h2></div>
|
||||
<div class="activity-tools"><span id="fleet-summary">СОСТОЯНИЕ НЕ ЗАГРУЖЕНО</span><button id="refresh-fleet" class="icon-button" type="button" aria-label="Обновить состояние моделей" title="Обновить состояние моделей">↻</button></div>
|
||||
</header>
|
||||
<div class="fleet-overview"><div><span>Выбранный tier</span><strong id="fleet-selected">—</strong></div><p id="fleet-explanation">Проверка доступности провайдеров ещё не выполнялась.</p></div>
|
||||
<div id="fleet-list" class="fleet-list" aria-live="polite" aria-busy="false"><p class="register-empty">Состояние fleet ещё не загружено.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="agent-panel" aria-labelledby="agent-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Work / durable execution</p><h2 id="agent-title">Агентская работа</h2></div>
|
||||
<div class="activity-tools"><span id="agent-summary">DELEGATION НЕ ВЫБРАНА</span><button id="refresh-agent" class="icon-button" type="button" aria-label="Обновить агентскую работу" title="Обновить агентскую работу">↻</button></div>
|
||||
</header>
|
||||
<div id="agent-empty" class="agent-empty">Укажите delegation ID в расширенных идентификаторах, чтобы прочитать durable state.</div>
|
||||
<div id="agent-details" class="agent-details" hidden>
|
||||
<div><span>Состояние delegation</span><strong id="agent-delegation-state">—</strong></div>
|
||||
<div><span>Run / implementation</span><strong id="agent-run-state">—</strong></div>
|
||||
<div><span>Последний blocker</span><strong id="agent-blocker">—</strong></div>
|
||||
<p id="agent-next-step">—</p>
|
||||
</div>
|
||||
<div class="agent-worker-status"><span>Executor capability</span><strong id="agent-worker-state">ПРОВЕРКА НЕ ВЫПОЛНЯЛАСЬ</strong><p id="agent-worker-message">Статус worker виден только в авторизованной диагностике.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
|
||||
<div class="activity-tools">
|
||||
@@ -143,7 +220,44 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-grid">
|
||||
<section id="learning" class="learning-panel" aria-labelledby="learning-title" data-page="learning">
|
||||
<header>
|
||||
<div><p>Learning / evidence</p><h2 id="learning-title">Опыт и reflection</h2></div>
|
||||
<div class="activity-tools">
|
||||
<span id="learning-count">0 СИГНАЛОВ</span>
|
||||
<button id="refresh-learning" class="icon-button" type="button" aria-label="Обновить опыт" title="Обновить опыт">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="learning-grid">
|
||||
<article>
|
||||
<h3>Experience</h3>
|
||||
<div id="experience-list" class="learning-list" aria-live="polite" aria-busy="false"><p class="register-empty">Опыт ещё не загружен.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Reflection</h3>
|
||||
<div id="reflection-list" class="learning-list" aria-live="polite" aria-busy="false"><p class="register-empty">Reflection ещё не загружен.</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Skills</h3>
|
||||
<div id="skill-list" class="learning-list" aria-live="polite" aria-busy="false"><p class="register-empty">Skill profile ещё не загружен.</p></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings" class="settings-panel" aria-labelledby="settings-title" data-page="settings">
|
||||
<header class="panel-heading">
|
||||
<div><p>Локальные предпочтения</p><h2 id="settings-title">Настройки рабочего кабинета</h2></div>
|
||||
<span id="settings-saved" class="settings-saved">Сохраняются в этом браузере</span>
|
||||
</header>
|
||||
<form id="settings-form" class="settings-grid">
|
||||
<label class="setting-field"><span>Плотность данных</span><small>Выберите, сколько строк помещается на экране.</small><select id="setting-density"><option value="comfortable">Комфортная</option><option value="compact">Компактная</option></select></label>
|
||||
<label class="setting-field"><span>Автообновление</span><small>Повторная проверка состояния без изменения данных.</small><select id="setting-refresh"><option value="off">Выключено</option><option value="30">Каждые 30 секунд</option><option value="60">Каждую минуту</option><option value="300">Каждые 5 минут</option></select></label>
|
||||
<label class="setting-check"><input id="setting-motion" type="checkbox"><span><b>Сократить анимацию</b><small>Оставить спокойный режим для длительной работы.</small></span></label>
|
||||
<div class="settings-actions"><button id="save-settings" class="primary-button compact-button" type="submit"><span>Сохранить настройки</span><span aria-hidden="true">→</span></button><button id="reset-settings" class="text-button" type="button">Сбросить</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="data-grid" data-page="overview">
|
||||
<article class="data-card focus-card">
|
||||
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
|
||||
<dl>
|
||||
@@ -164,6 +278,7 @@
|
||||
<div><dt>Ревизия</dt><dd id="task-revision">—</dd></div>
|
||||
</dl>
|
||||
<p id="task-message" class="card-message">Укажите задачу для проверки относительно активного фокуса.</p>
|
||||
<div class="id-actions"><button class="id-action" data-use-value="task-value" data-target-id="task-id" type="button">Использовать ID задачи</button><button class="id-action icon-id-action" data-copy-value="task-value" type="button" aria-label="Скопировать ID задачи" title="Скопировать ID">⧉</button></div>
|
||||
</article>
|
||||
|
||||
<article class="data-card prospective-card">
|
||||
@@ -181,6 +296,7 @@
|
||||
<div><dt>Ревизия</dt><dd id="prospective-revision">—</dd></div>
|
||||
</dl>
|
||||
<p id="prospective-message" class="card-message">Укажите идентификатор ProspectiveItem для проверки владельца и состояния.</p>
|
||||
<div class="id-actions"><button class="id-action" data-use-value="prospective-value" data-target-id="prospective-id" type="button">Использовать ID ожидания</button><button class="id-action icon-id-action" data-copy-value="prospective-value" type="button" aria-label="Скопировать ID ожидания" title="Скопировать ID">⧉</button></div>
|
||||
<section class="commit-zone" aria-labelledby="commit-zone-title">
|
||||
<div class="commit-zone-copy">
|
||||
<p>Commit / контролируемый переход</p>
|
||||
@@ -216,6 +332,7 @@
|
||||
<div><dt>Ревизия</dt><dd id="capability-revision">—</dd></div>
|
||||
<div><dt>Наблюдалось</dt><dd id="capability-observed">—</dd></div>
|
||||
</dl>
|
||||
<div class="id-actions"><button class="id-action" data-use-value="capability-value" data-target-id="capability-id" type="button">Использовать ID возможности</button><button class="id-action icon-id-action" data-copy-value="capability-value" type="button" aria-label="Скопировать ID возможности" title="Скопировать ID">⧉</button></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
@@ -262,6 +379,6 @@
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<script src="/assets/console.js" defer></script>
|
||||
<script src="/assets/console.js?v=20260821-context" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -166,7 +166,7 @@ func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string,
|
||||
|
||||
func loadTaskPacket(root, taskPath string) (TaskPacket, error) {
|
||||
var taskPacket TaskPacket
|
||||
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, false); err != nil {
|
||||
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, true); err != nil {
|
||||
return TaskPacket{}, fmt.Errorf("task packet: %w", err)
|
||||
}
|
||||
return taskPacket, nil
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var required = []string{
|
||||
"task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent",
|
||||
"requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta",
|
||||
"contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges",
|
||||
"persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios",
|
||||
"required_tests", "terminal_gap_states",
|
||||
}
|
||||
|
||||
func main() {
|
||||
activePath := filepath.FromSlash("dev/ACTIVE_TASK.json")
|
||||
var active struct {
|
||||
TaskFile string `json:"task_file"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskRevision int `json:"task_revision"`
|
||||
}
|
||||
if err := decode(activePath, &active); err != nil {
|
||||
fail("active pointer: %v", err)
|
||||
}
|
||||
if active.TaskFile == "" || active.TaskID == "" || active.TaskRevision < 1 {
|
||||
fail("active pointer is incomplete")
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := decode(filepath.FromSlash(active.TaskFile), &raw); err != nil {
|
||||
fail("task packet: %v", err)
|
||||
}
|
||||
for _, key := range required {
|
||||
if _, ok := raw[key]; !ok {
|
||||
fail("task packet missing required field %q", key)
|
||||
}
|
||||
}
|
||||
var packet struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskRevision int `json:"task_revision"`
|
||||
}
|
||||
if err := decode(filepath.FromSlash(active.TaskFile), &packet); err != nil {
|
||||
fail("task identity: %v", err)
|
||||
}
|
||||
if packet.TaskID != active.TaskID || packet.TaskRevision != active.TaskRevision {
|
||||
fail("active pointer and packet identity differ")
|
||||
}
|
||||
fmt.Printf("independent governance verification: OK task=%s revision=%d\n", packet.TaskID, packet.TaskRevision)
|
||||
}
|
||||
|
||||
func decode(path string, target any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, target)
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "independent verifier: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+32
-11
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -24,19 +23,41 @@ type Registry struct {
|
||||
BoundaryClass string `json:"boundary_class"`
|
||||
MayDependOn []string `json:"may_depend_on"`
|
||||
Forbidden []string `json:"forbidden"`
|
||||
Privileged bool `json:"privileged"`
|
||||
} `json:"modules"`
|
||||
ProtectedPaths []string `json:"protected_paths"`
|
||||
}
|
||||
|
||||
type TaskPacket struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskRevision int `json:"task_revision"`
|
||||
BaseRepoRevision string `json:"base_repo_revision"`
|
||||
RiskTier string `json:"risk_tier"`
|
||||
Intent string `json:"intent"`
|
||||
AllowedChangeScope struct {
|
||||
Paths []string `json:"paths"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskRevision int `json:"task_revision"`
|
||||
ArchitectureRevision string `json:"architecture_revision"`
|
||||
BaseRepoRevision string `json:"base_repo_revision"`
|
||||
RiskTier string `json:"risk_tier"`
|
||||
Intent string `json:"intent"`
|
||||
RequirementIDs []string `json:"requirement_ids"`
|
||||
AllowedChangeScope struct {
|
||||
Modules []string `json:"modules"`
|
||||
Paths []string `json:"paths"`
|
||||
} `json:"allowed_change_scope"`
|
||||
ForbiddenChangeScope struct {
|
||||
Paths []string `json:"paths"`
|
||||
} `json:"forbidden_change_scope"`
|
||||
AllowedArchitectureDelta map[string]int `json:"allowed_architecture_delta"`
|
||||
Contracts struct {
|
||||
Consume []string `json:"consume"`
|
||||
MayChange []string `json:"may_change"`
|
||||
} `json:"contracts"`
|
||||
SemanticOwner string `json:"semantic_owner"`
|
||||
CanonicalState []string `json:"canonical_state"`
|
||||
StateTransitions []string `json:"state_transitions"`
|
||||
SideEffectsAndPrivileges []string `json:"side_effects_and_privileges"`
|
||||
PersistenceAndMigrations []string `json:"persistence_and_migrations"`
|
||||
FailureRetryRestart []string `json:"failure_retry_restart"`
|
||||
Assumptions []string `json:"assumptions"`
|
||||
AcceptanceScenarios []string `json:"acceptance_scenarios"`
|
||||
RequiredTests []string `json:"required_tests"`
|
||||
TerminalGapStates []string `json:"terminal_gap_states"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -207,12 +228,12 @@ func preflight(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskPacket, _, _, err := loadSelectedTask(root, selection)
|
||||
taskPacket, taskPath, _, err := loadSelectedTask(root, selection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taskPacket.TaskID == "" || taskPacket.Intent == "" || taskPacket.RiskTier == "" {
|
||||
return errors.New("task packet missing task_id, intent, or risk_tier")
|
||||
if err := validateTaskPacket(root, taskPacket, taskPath); err != nil {
|
||||
return err
|
||||
}
|
||||
registry, err := loadRegistry()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var requiredPacketKeys = []string{
|
||||
"task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent",
|
||||
"requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta",
|
||||
"contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges",
|
||||
"persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios",
|
||||
"required_tests", "terminal_gap_states",
|
||||
}
|
||||
|
||||
var requiredDeltaKeys = []string{
|
||||
"new_cross_module_dependencies", "new_canonical_owners", "new_privileges",
|
||||
"new_third_party_dependencies", "new_database_owner_roles",
|
||||
}
|
||||
|
||||
func validateTaskPacket(root string, task TaskPacket, taskPath string) error {
|
||||
if task.TaskID == "" || task.TaskRevision < 1 || task.ArchitectureRevision == "" || task.BaseRepoRevision == "" || task.RiskTier == "" || task.Intent == "" {
|
||||
return fmt.Errorf("task %s is incomplete: identity, revision, architecture_revision, base_repo_revision, risk_tier and intent are required", task.TaskID)
|
||||
}
|
||||
if taskPath == "" || !matchesAny(taskPath, mustCompile([]string{"dev/tasks/*.json"})) {
|
||||
return fmt.Errorf("task %s must be selected from dev/tasks", task.TaskID)
|
||||
}
|
||||
if task.RiskTier < "A" || task.RiskTier > "E" {
|
||||
return fmt.Errorf("task %s has invalid risk_tier %q", task.TaskID, task.RiskTier)
|
||||
}
|
||||
if len(task.RequirementIDs) == 0 || len(task.AcceptanceScenarios) == 0 || len(task.RequiredTests) == 0 || len(task.TerminalGapStates) == 0 {
|
||||
return fmt.Errorf("task %s must declare requirements, acceptance_scenarios, required_tests and terminal_gap_states", task.TaskID)
|
||||
}
|
||||
if len(task.AllowedChangeScope.Paths) == 0 {
|
||||
return fmt.Errorf("task %s must declare allowed_change_scope.paths", task.TaskID)
|
||||
}
|
||||
if err := validateScopeOverlap(task.AllowedChangeScope.Paths, task.ForbiddenChangeScope.Paths); err != nil {
|
||||
return fmt.Errorf("task %s scope: %w", task.TaskID, err)
|
||||
}
|
||||
if err := validateDelta(task.AllowedArchitectureDelta); err != nil {
|
||||
return fmt.Errorf("task %s architecture delta: %w", task.TaskID, err)
|
||||
}
|
||||
if err := validateRequiredTests(task.RequiredTests); err != nil {
|
||||
return fmt.Errorf("task %s required_tests: %w", task.TaskID, err)
|
||||
}
|
||||
if err := validateArchitectureRevision(root, task.ArchitectureRevision); err != nil {
|
||||
return fmt.Errorf("task %s architecture_revision: %w", task.TaskID, err)
|
||||
}
|
||||
if _, err := resolveBaseCommit(root, task.BaseRepoRevision); err != nil {
|
||||
return fmt.Errorf("task %s base_repo_revision: %w", task.TaskID, err)
|
||||
}
|
||||
if err := validateObservedDelta(root, task); err != nil {
|
||||
return fmt.Errorf("task %s observed architecture delta: %w", task.TaskID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredTests(tests []string) error {
|
||||
joined := strings.Join(tests, "\n")
|
||||
for _, required := range []string{"go test", "go vet", "preflight", "check-diff", "git diff --check", "independent"} {
|
||||
if !strings.Contains(joined, required) {
|
||||
return fmt.Errorf("missing required governance evidence %q", required)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateObservedDelta(root string, task TaskPacket) error {
|
||||
changed, err := changedPaths(root, task.BaseRepoRevision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRegistryDelta(root, task); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, changedPath := range changed {
|
||||
if (strings.HasPrefix(changedPath, "security/") || strings.HasPrefix(changedPath, "deploy/")) && task.AllowedArchitectureDelta["new_privileges"] == 0 {
|
||||
return fmt.Errorf("privileged surface changed with new_privileges=0: %s", changedPath)
|
||||
}
|
||||
if strings.HasPrefix(changedPath, "database/") && task.AllowedArchitectureDelta["new_database_owner_roles"] == 0 {
|
||||
return fmt.Errorf("database surface changed with new_database_owner_roles=0: %s", changedPath)
|
||||
}
|
||||
if (changedPath == "go.mod" || changedPath == "go.sum") && task.AllowedArchitectureDelta["new_third_party_dependencies"] == 0 {
|
||||
return fmt.Errorf("dependency manifest changed with new_third_party_dependencies=0: %s", changedPath)
|
||||
}
|
||||
if strings.HasPrefix(changedPath, "contracts/") && len(task.Contracts.MayChange) == 0 {
|
||||
return fmt.Errorf("contract surface changed but contracts.may_change is empty: %s", changedPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRegistryDelta(root string, task TaskPacket) error {
|
||||
baseBytes, err := runGit(root, "show", task.BaseRepoRevision+":architecture/architecture-registry.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read base architecture registry: %w", err)
|
||||
}
|
||||
currentBytes, err := os.ReadFile(filepath.Join(root, "architecture", "architecture-registry.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var base, current Registry
|
||||
if err := json.Unmarshal(baseBytes, &base); err != nil {
|
||||
return fmt.Errorf("parse base architecture registry: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(currentBytes, ¤t); err != nil {
|
||||
return fmt.Errorf("parse current architecture registry: %w", err)
|
||||
}
|
||||
if task.AllowedArchitectureDelta["new_canonical_owners"] == 0 {
|
||||
for name := range current.Modules {
|
||||
if _, ok := base.Modules[name]; !ok {
|
||||
return fmt.Errorf("new canonical module owner %q with new_canonical_owners=0", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if task.AllowedArchitectureDelta["new_cross_module_dependencies"] == 0 {
|
||||
for name, module := range current.Modules {
|
||||
previous, ok := base.Modules[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
old := map[string]bool{}
|
||||
for _, dep := range previous.MayDependOn {
|
||||
old[dep] = true
|
||||
}
|
||||
for _, dep := range module.MayDependOn {
|
||||
if !old[dep] {
|
||||
return fmt.Errorf("new dependency %q in module %q with new_cross_module_dependencies=0", dep, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if task.AllowedArchitectureDelta["new_privileges"] == 0 {
|
||||
for name, module := range current.Modules {
|
||||
if previous, ok := base.Modules[name]; ok && module.Privileged && !previous.Privileged {
|
||||
return fmt.Errorf("new privilege in module %q with new_privileges=0", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDelta(delta map[string]int) error {
|
||||
if len(delta) != len(requiredDeltaKeys) {
|
||||
return fmt.Errorf("must contain exactly %s", strings.Join(requiredDeltaKeys, ", "))
|
||||
}
|
||||
for _, key := range requiredDeltaKeys {
|
||||
value, ok := delta[key]
|
||||
if !ok || value < 0 {
|
||||
return fmt.Errorf("%s must be a non-negative integer", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArchitectureRevision(root, revision string) error {
|
||||
if strings.HasPrefix(revision, "ADR-") {
|
||||
matches, err := filepath.Glob(filepath.Join(root, "architecture", "adrs", revision+"-*.md"))
|
||||
if err != nil || len(matches) != 1 {
|
||||
return fmt.Errorf("accepted ADR %q is missing or ambiguous", revision)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(revision, "ARCH-") {
|
||||
matches, err := filepath.Glob(filepath.Join(root, "dev", "tasks", revision+"*.json"))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return fmt.Errorf("architecture task %q is missing", revision)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("must reference an accepted ADR-NNNN or architecture task")
|
||||
}
|
||||
|
||||
func validateScopeOverlap(allowed, forbidden []string) error {
|
||||
allowedPatterns, err := compileScopePatterns(allowed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forbiddenPatterns, err := compileScopePatterns(forbidden)
|
||||
if err != nil {
|
||||
return fmt.Errorf("forbidden_change_scope: %w", err)
|
||||
}
|
||||
for _, a := range allowedPatterns {
|
||||
for _, f := range forbiddenPatterns {
|
||||
if scopePatternsOverlap(a, f) {
|
||||
return fmt.Errorf("allowed path %q overlaps forbidden path %q", a.raw, f.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scopePatternsOverlap(a, b scopePattern) bool {
|
||||
if a.subtree != "" && b.subtree != "" {
|
||||
return a.subtree == b.subtree || strings.HasPrefix(a.subtree, b.subtree+"/") || strings.HasPrefix(b.subtree, a.subtree+"/")
|
||||
}
|
||||
if a.subtree != "" {
|
||||
return b.raw == a.subtree || strings.HasPrefix(b.raw, a.subtree+"/")
|
||||
}
|
||||
if b.subtree != "" {
|
||||
return a.raw == b.subtree || strings.HasPrefix(a.raw, b.subtree+"/")
|
||||
}
|
||||
return a.raw == b.raw
|
||||
}
|
||||
|
||||
func mustCompile(values []string) []scopePattern {
|
||||
patterns, err := compileScopePatterns(values)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateScopeOverlapRejectsAllowedForbiddenConflict(t *testing.T) {
|
||||
err := validateScopeOverlap([]string{"cmd/creator-web/server.go"}, []string{"cmd/creator-web/**"})
|
||||
if err == nil || !strings.Contains(err.Error(), "overlaps forbidden") {
|
||||
t.Fatalf("overlap error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiredTestsRejectsIncompleteEvidence(t *testing.T) {
|
||||
err := validateRequiredTests([]string{"go test ./..."})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required governance evidence") {
|
||||
t.Fatalf("required test error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeltaRejectsUnknownOrMissingFields(t *testing.T) {
|
||||
err := validateDelta(map[string]int{"new_privileges": 0})
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly") {
|
||||
t.Fatalf("delta error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
|
||||
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
|
||||
pollInterval := flag.Duration("poll-interval", 15*time.Second, "database-owned idle wake-up interval")
|
||||
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")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password}
|
||||
repository, err := postgres.OpenEpistemics(ctx, config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
for {
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
candidate, claimErr := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds())
|
||||
if claimErr == nil {
|
||||
// This worker only closes the durable evaluation loop. Any cognitive
|
||||
// proposal remains a candidate and must be admitted by a separate governed flow.
|
||||
if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("resolved reflection candidate %s\n", candidate.ID)
|
||||
} else if *once {
|
||||
fmt.Println("no reflection candidate")
|
||||
}
|
||||
if *once {
|
||||
return
|
||||
}
|
||||
if err := repository.WaitForReflectionCandidate(ctx, *pollInterval); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
researchadapter "git.cin.su/m/Assistant/adapters/research"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/application"
|
||||
"git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
repository, err := postgres.OpenEpistemics(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "research-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "research-runtime", Probes: []health.Probe{{Name: "epistemics-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.HandleFunc("POST /v1/research/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.ResearchEvaluationRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid research evidence", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), request, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
connector := researchadapter.RepositoryConnector{Root: os.Getenv("RESEARCH_SOURCE_ROOT")}
|
||||
mux.HandleFunc("POST /v1/research/repository/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.RepositoryResearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid repository research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidate, err := connector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
webConnector := researchadapter.HTTPConnector{AllowedHosts: allowedHosts(os.Getenv("RESEARCH_HTTP_ALLOWED_HOSTS")), Client: &http.Client{Timeout: 5 * time.Second}}
|
||||
mux.HandleFunc("POST /v1/research/web/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.WebResearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidate, err := webConnector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8087"
|
||||
}
|
||||
log.Printf("research-runtime listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func allowedHosts(value string) map[string]struct{} {
|
||||
result := map[string]struct{}{}
|
||||
for _, host := range strings.Split(value, ",") {
|
||||
if host = strings.ToLower(strings.TrimSpace(host)); host != "" {
|
||||
result[host] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := secrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
repository, err := postgres.OpenWork(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "work-graph-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "work-graph-runtime", Probes: []health.Probe{{Name: "work-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.HandleFunc("POST /v1/work-graphs/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph schedule request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.AdmitAndSchedule(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphCompleteRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph completion request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.CompleteGraphNode(r.Context(), repository, request.GraphID, request.Revision, request.NodeID, request.Succeeded)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8088"
|
||||
}
|
||||
log.Printf("work-graph-runtime listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := secrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: path})
|
||||
}
|
||||
@@ -2,4 +2,23 @@ package artifact
|
||||
|
||||
import "git.cin.su/m/Assistant/platform/ids"
|
||||
|
||||
import "errors"
|
||||
|
||||
type ArtifactID ids.ID
|
||||
|
||||
type AgentArtifactAttestation struct {
|
||||
ArtifactID ArtifactID
|
||||
ProducerRunID string
|
||||
SourceRevision string
|
||||
WorktreeID string
|
||||
ContentDigest string
|
||||
TestEvidenceRef string
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a AgentArtifactAttestation) Validate() error {
|
||||
if a.ArtifactID == "" || a.ProducerRunID == "" || a.SourceRevision == "" || a.WorktreeID == "" || a.ContentDigest == "" || a.TestEvidenceRef == "" || a.CreatedAtUnixMillis < 1 {
|
||||
return errors.New("agent artifact attestation is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package capability
|
||||
|
||||
type AdmissionComplexity string
|
||||
type AdmissionDecisionState string
|
||||
type AdmissionReason string
|
||||
|
||||
const (
|
||||
AdmissionSimple AdmissionComplexity = "SIMPLE"
|
||||
AdmissionNormal AdmissionComplexity = "NORMAL"
|
||||
AdmissionHeavy AdmissionComplexity = "HEAVY"
|
||||
AdmissionAdmitted AdmissionDecisionState = "ADMITTED"
|
||||
AdmissionRejected AdmissionDecisionState = "REJECTED"
|
||||
AdmissionReasonNone AdmissionReason = "NONE"
|
||||
AdmissionReasonConcurrency AdmissionReason = "CONCURRENCY_LIMIT"
|
||||
AdmissionReasonAcceptedHour AdmissionReason = "ACCEPTED_PER_HOUR_LIMIT"
|
||||
AdmissionReasonDeadline AdmissionReason = "DEADLINE"
|
||||
AdmissionReasonInputBudget AdmissionReason = "INPUT_BUDGET"
|
||||
AdmissionReasonOutputBudget AdmissionReason = "OUTPUT_BUDGET"
|
||||
)
|
||||
|
||||
type AdmissionPolicy struct {
|
||||
ID string
|
||||
MaxConcurrent int
|
||||
MaxAcceptedPerHour int
|
||||
MaxInputBytes int
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
type AdmissionRequest struct {
|
||||
Complexity AdmissionComplexity `json:"complexity"`
|
||||
InputBytes int `json:"input_bytes"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
DeadlineMillis int64 `json:"deadline_millis"`
|
||||
}
|
||||
|
||||
type AdmissionCounters struct {
|
||||
Concurrent int
|
||||
AcceptedLastHour int
|
||||
}
|
||||
|
||||
type AdmissionDecision struct {
|
||||
State AdmissionDecisionState `json:"state"`
|
||||
Reason AdmissionReason `json:"reason"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
type AdmissionLease struct {
|
||||
ID string `json:"id"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
func Admit(request AdmissionRequest, policy AdmissionPolicy, counters AdmissionCounters, nowMillis int64) AdmissionDecision {
|
||||
decision := AdmissionDecision{State: AdmissionAdmitted, Reason: AdmissionReasonNone, PolicyID: policy.ID}
|
||||
if policy.ID == "" || policy.MaxConcurrent < 1 || policy.MaxAcceptedPerHour < 1 || policy.MaxInputBytes < 1 || policy.MaxOutputBytes < 1 {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID}
|
||||
}
|
||||
if counters.Concurrent >= policy.MaxConcurrent {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonConcurrency, PolicyID: policy.ID}
|
||||
}
|
||||
if counters.AcceptedLastHour >= policy.MaxAcceptedPerHour {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonAcceptedHour, PolicyID: policy.ID}
|
||||
}
|
||||
if request.InputBytes < 0 || request.InputBytes > policy.MaxInputBytes {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID}
|
||||
}
|
||||
if request.MaxOutputBytes < 1 || request.MaxOutputBytes > policy.MaxOutputBytes {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonOutputBudget, PolicyID: policy.ID}
|
||||
}
|
||||
if request.DeadlineMillis > 0 && request.DeadlineMillis <= nowMillis {
|
||||
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonDeadline, PolicyID: policy.ID}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
@@ -23,6 +23,9 @@ type OperationalState string
|
||||
type EvaluationResult string
|
||||
type EvaluationID ids.ID
|
||||
type PerformanceEvidenceID ids.ID
|
||||
type BillingMode string
|
||||
type AuthMode string
|
||||
type SelectionObjective string
|
||||
|
||||
const (
|
||||
QualificationDiscovered QualificationState = "DISCOVERED"
|
||||
@@ -52,6 +55,24 @@ const (
|
||||
EvaluationUnknown EvaluationResult = "UNKNOWN"
|
||||
EvaluationSupported EvaluationResult = "SUPPORTED"
|
||||
EvaluationUnsupported EvaluationResult = "UNSUPPORTED"
|
||||
|
||||
BillingUnknown BillingMode = "UNKNOWN"
|
||||
BillingFree BillingMode = "FREE"
|
||||
BillingPerInput BillingMode = "PER_INPUT_UNIT"
|
||||
BillingPerOutput BillingMode = "PER_OUTPUT_UNIT"
|
||||
BillingPerRequest BillingMode = "PER_REQUEST"
|
||||
|
||||
AuthNone AuthMode = "NONE"
|
||||
AuthKey AuthMode = "API" + "_KEY"
|
||||
AuthBearer AuthMode = "BEARER"
|
||||
AuthUsernamePassword AuthMode = "USERNAME_PASSWORD"
|
||||
AuthOAuth AuthMode = "OAUTH"
|
||||
AuthSecretFile AuthMode = "SECRET_FILE"
|
||||
|
||||
SelectionBalanced SelectionObjective = "BALANCED"
|
||||
SelectionQuality SelectionObjective = "QUALITY"
|
||||
SelectionSpeed SelectionObjective = "SPEED"
|
||||
SelectionEconomy SelectionObjective = "ECONOMY"
|
||||
)
|
||||
|
||||
// ProviderInstance contains only opaque references to endpoint and credentials.
|
||||
@@ -66,20 +87,26 @@ type ProviderInstance struct {
|
||||
Qualification QualificationState
|
||||
Health HealthState
|
||||
ObservedAtUnixMillis int64
|
||||
BillingMode BillingMode
|
||||
AuthMode AuthMode
|
||||
InputCostMicros *int64
|
||||
OutputCostMicros *int64
|
||||
RequestCostMicros *int64
|
||||
}
|
||||
|
||||
type ModelDescriptor struct {
|
||||
ID ModelDescriptorID
|
||||
Revision int64
|
||||
ProviderInstanceID ProviderInstanceID
|
||||
ProviderModelID ProviderModelID
|
||||
Fingerprint ModelFingerprint
|
||||
MaxContextBytes int64
|
||||
Modalities []ModelModality
|
||||
Capabilities []ModelCapability
|
||||
Qualification QualificationState
|
||||
Health HealthState
|
||||
ObservedAtUnixMillis int64
|
||||
ID ModelDescriptorID
|
||||
Revision int64
|
||||
ProviderInstanceID ProviderInstanceID
|
||||
ProviderModelID ProviderModelID
|
||||
Fingerprint ModelFingerprint
|
||||
MaxContextBytes int64
|
||||
Modalities []ModelModality
|
||||
Capabilities []ModelCapability
|
||||
Qualification QualificationState
|
||||
Health HealthState
|
||||
ObservedAtUnixMillis int64
|
||||
IntelligenceScoreMillis *int64
|
||||
}
|
||||
|
||||
type ProviderHealthObservation struct {
|
||||
@@ -125,6 +152,8 @@ type CognitiveRequirement struct {
|
||||
InputBytes int64
|
||||
MaxOutputBytes int64
|
||||
ExcludedBindingIDs []CapabilityBindingID
|
||||
Objective SelectionObjective
|
||||
MaxCostMicros *int64
|
||||
}
|
||||
|
||||
// ResolvedCognitiveBinding is a provider-neutral invocation snapshot selected
|
||||
@@ -206,6 +235,23 @@ type CognitiveCanaryPromotion struct {
|
||||
Evaluation EvaluatedCapability
|
||||
}
|
||||
|
||||
type CognitiveActivationPromotionRequest struct {
|
||||
PromotionID ids.ID
|
||||
RequestedByPrincipalID identity.PrincipalID
|
||||
CapabilityID CapabilityID
|
||||
BindingID CapabilityBindingID
|
||||
ExpectedBindingRevision int64
|
||||
EvaluationID EvaluationID
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type CognitiveActivationPromotion struct {
|
||||
PromotionID ids.ID
|
||||
ExpectedBindingRevision int64
|
||||
Binding CognitiveBinding
|
||||
Evaluation EvaluatedCapability
|
||||
}
|
||||
|
||||
// CognitiveDiscoverySnapshot is provider-neutral observed inventory submitted
|
||||
// to the Capability owner. The owner assigns immutable revisions and always
|
||||
// registers discovered bindings as DISCOVERED/INACTIVE.
|
||||
@@ -222,18 +268,24 @@ type CognitiveDiscoverySnapshot struct {
|
||||
Health HealthState
|
||||
FailureClass CognitiveFailureType
|
||||
ObservedAtUnixMillis int64
|
||||
BillingMode BillingMode
|
||||
AuthMode AuthMode
|
||||
InputCostMicros *int64
|
||||
OutputCostMicros *int64
|
||||
RequestCostMicros *int64
|
||||
Models []DiscoveredModelSnapshot
|
||||
}
|
||||
|
||||
type DiscoveredModelSnapshot struct {
|
||||
ProviderModelID ProviderModelID
|
||||
Fingerprint ModelFingerprint
|
||||
MaxContextBytes int64
|
||||
Modalities []ModelModality
|
||||
Capabilities []ModelCapability
|
||||
Installed OperationalState
|
||||
Loaded OperationalState
|
||||
Invokable OperationalState
|
||||
ProviderModelID ProviderModelID
|
||||
Fingerprint ModelFingerprint
|
||||
MaxContextBytes int64
|
||||
Modalities []ModelModality
|
||||
Capabilities []ModelCapability
|
||||
Installed OperationalState
|
||||
Loaded OperationalState
|
||||
Invokable OperationalState
|
||||
IntelligenceScoreMillis *int64
|
||||
}
|
||||
|
||||
type CognitiveUsageObservation struct {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package capability
|
||||
|
||||
type FleetTier string
|
||||
|
||||
const (
|
||||
FleetTierFast FleetTier = "FAST"
|
||||
FleetTierMedium FleetTier = "MEDIUM"
|
||||
FleetTierHeavy FleetTier = "HEAVY"
|
||||
FleetTierLifeboat FleetTier = "LIFEBOAT"
|
||||
)
|
||||
|
||||
type FleetMember struct {
|
||||
ProviderInstanceID ProviderInstanceID `json:"provider_instance_id"`
|
||||
ModelDescriptorID ModelDescriptorID `json:"model_descriptor_id"`
|
||||
Tier FleetTier `json:"tier"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FleetReadiness struct {
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Members []FleetMember `json:"members"`
|
||||
ReadyTiers []FleetTier `json:"ready_tiers"`
|
||||
FallbackTier FleetTier `json:"fallback_tier"`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package capability
|
||||
|
||||
import "errors"
|
||||
|
||||
type QualificationManifestID string
|
||||
type QualificationDimension string
|
||||
|
||||
const (
|
||||
DimensionDialogue QualificationDimension = "DIALOGUE"
|
||||
DimensionInterpretation QualificationDimension = "INTERPRETATION"
|
||||
DimensionCoding QualificationDimension = "CODING"
|
||||
DimensionResearch QualificationDimension = "RESEARCH"
|
||||
DimensionUncertainty QualificationDimension = "UNCERTAINTY"
|
||||
DimensionContext QualificationDimension = "CONTEXT"
|
||||
DimensionLatency QualificationDimension = "LATENCY"
|
||||
DimensionResources QualificationDimension = "RESOURCES"
|
||||
DimensionRestart QualificationDimension = "RESTART"
|
||||
)
|
||||
|
||||
type QualificationMeasurement struct {
|
||||
Dimension QualificationDimension
|
||||
Result EvaluationResult
|
||||
Artifact string
|
||||
}
|
||||
|
||||
type QualificationManifest struct {
|
||||
ID QualificationManifestID
|
||||
ModelDescriptorID ModelDescriptorID
|
||||
ModelRevision int64
|
||||
ModelFingerprint ModelFingerprint
|
||||
BenchmarkVersion string
|
||||
HoldoutCorpusDigest string
|
||||
LicenseEvidence string
|
||||
DataPolicyEvidence string
|
||||
Measurements []QualificationMeasurement
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (m QualificationManifest) Validate() error {
|
||||
if m.ID == "" || m.ModelDescriptorID == "" || m.ModelRevision < 1 || m.ModelFingerprint == "" || m.BenchmarkVersion == "" || m.HoldoutCorpusDigest == "" || m.LicenseEvidence == "" || m.DataPolicyEvidence == "" || m.ObservedAtUnixMillis < 1 {
|
||||
return errors.New("qualification manifest identity and evidence are incomplete")
|
||||
}
|
||||
required := map[QualificationDimension]bool{DimensionDialogue: false, DimensionInterpretation: false, DimensionCoding: false, DimensionResearch: false, DimensionUncertainty: false, DimensionContext: false, DimensionLatency: false, DimensionResources: false, DimensionRestart: false}
|
||||
for _, measurement := range m.Measurements {
|
||||
if measurement.Dimension == "" || measurement.Artifact == "" || measurement.Result == EvaluationUnknown {
|
||||
return errors.New("qualification measurement is incomplete")
|
||||
}
|
||||
if _, ok := required[measurement.Dimension]; ok {
|
||||
required[measurement.Dimension] = true
|
||||
}
|
||||
}
|
||||
for _, present := range required {
|
||||
if !present {
|
||||
return errors.New("qualification manifest lacks required measurement")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QualificationState projects evidence into a qualification result. It does
|
||||
// not activate a binding or mutate the model registry.
|
||||
func (m QualificationManifest) QualificationState() QualificationState {
|
||||
if m.Validate() != nil {
|
||||
return QualificationRejected
|
||||
}
|
||||
for _, measurement := range m.Measurements {
|
||||
if measurement.Result != EvaluationSupported {
|
||||
return QualificationRejected
|
||||
}
|
||||
}
|
||||
return QualificationValidated
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type CapabilityImplementationID ids.ID
|
||||
type CapabilityResolutionID ids.ID
|
||||
type CapabilityGapID ids.ID
|
||||
type CapabilityUsageObservationID ids.ID
|
||||
type SkillProfileID ids.ID
|
||||
type SkillEvidenceRef string
|
||||
type RequirementRef string
|
||||
type OriginalIntentRef string
|
||||
type SourceUtteranceRef string
|
||||
|
||||
type ImplementationKind string
|
||||
type ImplementationTargetRef string
|
||||
type AuthorityPolicyRef string
|
||||
type DataConstraint string
|
||||
type CapabilityResolutionStatus string
|
||||
type GapReason string
|
||||
type AcquisitionStrategyKind string
|
||||
type UsageOutcomeClass string
|
||||
type SkillLifecycleStatus string
|
||||
type FastPathDecisionState string
|
||||
type FastPathDeclineReason string
|
||||
|
||||
const (
|
||||
ImplementationInternalFunction ImplementationKind = "INTERNAL_FUNCTION"
|
||||
ImplementationCognitiveBinding ImplementationKind = "COGNITIVE_BINDING"
|
||||
ImplementationExternalAdapter ImplementationKind = "EXTERNAL_ADAPTER"
|
||||
ImplementationAgent ImplementationKind = "AGENT"
|
||||
|
||||
AuthorityPolicyCreatorDirectiveV1 AuthorityPolicyRef = "creator-directive-v1"
|
||||
AuthorityPolicyCognitiveGatewayV1 AuthorityPolicyRef = "cognitive-gateway-v1"
|
||||
|
||||
DataConstraintLocalOnly DataConstraint = "LOCAL_ONLY"
|
||||
DataConstraintExternalAllowed DataConstraint = "EXTERNAL_ALLOWED"
|
||||
DataConstraintNoCredentialForward DataConstraint = "NO_CREDENTIAL_FORWARD"
|
||||
|
||||
ResolutionAvailable CapabilityResolutionStatus = "AVAILABLE"
|
||||
ResolutionPartiallyAvailable CapabilityResolutionStatus = "PARTIALLY_AVAILABLE"
|
||||
ResolutionUnavailable CapabilityResolutionStatus = "UNAVAILABLE"
|
||||
ResolutionUnknown CapabilityResolutionStatus = "UNKNOWN"
|
||||
|
||||
GapNoImplementation GapReason = "NO_IMPLEMENTATION"
|
||||
GapUnhealthy GapReason = "UNHEALTHY"
|
||||
GapInactive GapReason = "INACTIVE"
|
||||
GapUnqualified GapReason = "UNQUALIFIED"
|
||||
GapUnauthorized GapReason = "UNAUTHORIZED"
|
||||
GapIncompatible GapReason = "INCOMPATIBLE"
|
||||
GapUnknown GapReason = "UNKNOWN"
|
||||
|
||||
AcquireUseAlternative AcquisitionStrategyKind = "USE_ALTERNATIVE"
|
||||
AcquireDiscoverExisting AcquisitionStrategyKind = "DISCOVER_EXISTING"
|
||||
AcquireConnectExisting AcquisitionStrategyKind = "CONNECT_EXISTING"
|
||||
AcquireConfigureExisting AcquisitionStrategyKind = "CONFIGURE_EXISTING"
|
||||
AcquireExternal AcquisitionStrategyKind = "ACQUIRE_EXTERNAL"
|
||||
AcquireBuildNew AcquisitionStrategyKind = "BUILD_NEW"
|
||||
AcquireRequestUserInput AcquisitionStrategyKind = "REQUEST_USER_INPUT"
|
||||
AcquireUnresolvableCurrently AcquisitionStrategyKind = "UNRESOLVABLE_CURRENTLY"
|
||||
|
||||
UsageSucceeded UsageOutcomeClass = "SUCCEEDED"
|
||||
UsageFailed UsageOutcomeClass = "FAILED"
|
||||
UsageSkipped UsageOutcomeClass = "SKIPPED"
|
||||
UsageUnknown UsageOutcomeClass = "UNKNOWN"
|
||||
|
||||
SkillCandidate SkillLifecycleStatus = "CANDIDATE"
|
||||
SkillEvaluating SkillLifecycleStatus = "EVALUATING"
|
||||
SkillValidated SkillLifecycleStatus = "VALIDATED"
|
||||
SkillDegraded SkillLifecycleStatus = "DEGRADED"
|
||||
SkillSuspended SkillLifecycleStatus = "SUSPENDED"
|
||||
SkillRetired SkillLifecycleStatus = "RETIRED"
|
||||
|
||||
FastPathApplied FastPathDecisionState = "APPLIED"
|
||||
FastPathDeclined FastPathDecisionState = "DECLINED"
|
||||
FastPathEscalate FastPathDecisionState = "ESCALATE_TO_THINK"
|
||||
|
||||
FastPathNotApplicable FastPathDeclineReason = "NOT_APPLICABLE"
|
||||
FastPathLowConfidence FastPathDeclineReason = "LOW_CONFIDENCE"
|
||||
FastPathOutOfDistribution FastPathDeclineReason = "OUT_OF_DISTRIBUTION"
|
||||
FastPathConflict FastPathDeclineReason = "CONFLICT"
|
||||
FastPathStale FastPathDeclineReason = "STALE"
|
||||
FastPathImplementationDown FastPathDeclineReason = "IMPLEMENTATION_UNAVAILABLE"
|
||||
)
|
||||
|
||||
// CapabilityDescriptor names what Domovoy can obtain or do. CapabilityKind is
|
||||
// intentionally extensible string semantics, not a closed-world enum.
|
||||
type CapabilityDescriptor struct {
|
||||
ID CapabilityID
|
||||
Kind CapabilityKind
|
||||
DisplayName string
|
||||
Description string
|
||||
Revision int64
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// CapabilityImplementation is the generic implementation/binding layer. It
|
||||
// may point at a cognitive binding, internal function, agent, adapter or later
|
||||
// provider type. CredentialRef is metadata only and never grants authority.
|
||||
type CapabilityImplementation struct {
|
||||
ID CapabilityImplementationID
|
||||
CapabilityID CapabilityID
|
||||
CapabilityKind CapabilityKind
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
Revision int64
|
||||
Kind ImplementationKind
|
||||
TargetRef ImplementationTargetRef
|
||||
CredentialRef CredentialRef
|
||||
Qualification QualificationState
|
||||
Activation ActivationState
|
||||
Health HealthState
|
||||
AuthorityPolicyRef AuthorityPolicyRef
|
||||
DataConstraints []DataConstraint
|
||||
CostScoreMillis *int64
|
||||
LatencyScoreMillis *int64
|
||||
ReliabilityMillis *int64
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type CapabilityResolutionRequirement struct {
|
||||
RequirementID RequirementRef
|
||||
CapabilityKind CapabilityKind
|
||||
CapabilityID CapabilityID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ActorID identity.ActorID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
RequiredConstraints []DataConstraint
|
||||
AllowCanary bool
|
||||
AuthorityContextRef AuthorityPolicyRef
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ResolvedCapabilityImplementation struct {
|
||||
Implementation CapabilityImplementation
|
||||
Selected bool
|
||||
Rank int
|
||||
}
|
||||
|
||||
type CapabilityResolution struct {
|
||||
ID CapabilityResolutionID
|
||||
Requirement CapabilityResolutionRequirement
|
||||
Status CapabilityResolutionStatus
|
||||
Eligible []ResolvedCapabilityImplementation
|
||||
ExcludedReasons map[CapabilityImplementationID]GapReason
|
||||
Gap *CapabilityGap
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type CapabilityGap struct {
|
||||
ID CapabilityGapID
|
||||
RequirementID RequirementRef
|
||||
CapabilityKind CapabilityKind
|
||||
CapabilityID CapabilityID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
Reason GapReason
|
||||
Detail string
|
||||
Strategies []AcquisitionStrategyCandidate
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type AcquisitionStrategyCandidate struct {
|
||||
Kind AcquisitionStrategyKind
|
||||
Reason string
|
||||
}
|
||||
|
||||
type CapabilityUsageObservation struct {
|
||||
ID CapabilityUsageObservationID
|
||||
ImplementationID CapabilityImplementationID
|
||||
ImplementationRevision int64
|
||||
CapabilityID CapabilityID
|
||||
CapabilityKind CapabilityKind
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
Outcome UsageOutcomeClass
|
||||
OutcomeRef string
|
||||
LatencyMillis *int64
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// SkillProfile describes a reusable approach/pattern/capability composition.
|
||||
// It is derived from evidence and is never authority by itself.
|
||||
type SkillProfile struct {
|
||||
ID SkillProfileID
|
||||
Revision int64
|
||||
Name string
|
||||
Description string
|
||||
ApplicableContext []string
|
||||
ImplementationRef string
|
||||
ImplementationVersion string
|
||||
Status SkillLifecycleStatus
|
||||
SupportingExperienceRefs []SkillEvidenceRef
|
||||
KnownFailureExperienceRefs []SkillEvidenceRef
|
||||
ConfidenceMillis int64
|
||||
LastValidatedUnixMillis int64
|
||||
ObservedFailureCount int64
|
||||
ObservedCorrectionCount int64
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type SkillOutcomeObservation struct {
|
||||
ExperienceRef SkillEvidenceRef
|
||||
Outcome UsageOutcomeClass
|
||||
UserCorrected bool
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// FastPathDecision is an explicit optimization decision. Decline/escalation is
|
||||
// successful safety behavior, not a failure to answer.
|
||||
type FastPathDecision struct {
|
||||
SkillProfileID SkillProfileID
|
||||
Decision FastPathDecisionState
|
||||
DeclineReason FastPathDeclineReason
|
||||
RequiresCognition bool
|
||||
Explanation string
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
type CapabilityID ids.ID
|
||||
type CapabilityBindingID ids.ID
|
||||
type CapabilityKind string
|
||||
|
||||
type BindingState string
|
||||
type BindingProtocol string
|
||||
@@ -16,6 +17,18 @@ const (
|
||||
BindingRetired BindingState = "RETIRED"
|
||||
|
||||
ProtocolIdempotentReceiptV1 BindingProtocol = "IDEMPOTENT_RECEIPT_V1"
|
||||
|
||||
CapabilityKindReadEmail CapabilityKind = "READ_EMAIL"
|
||||
CapabilityKindSendEmail CapabilityKind = "SEND_EMAIL"
|
||||
CapabilityKindReadDatabase CapabilityKind = "READ_DATABASE"
|
||||
CapabilityKindQueryLogs CapabilityKind = "QUERY_LOGS"
|
||||
CapabilityKindInspectProject CapabilityKind = "INSPECT_PROJECT"
|
||||
CapabilityKindModifyCode CapabilityKind = "MODIFY_CODE"
|
||||
CapabilityKindRunTests CapabilityKind = "RUN_TESTS"
|
||||
CapabilityKindWebResearch CapabilityKind = "WEB_RESEARCH"
|
||||
CapabilityKindCognitiveReasoning CapabilityKind = "COGNITIVE_REASONING"
|
||||
CapabilityKindScheduleReminder CapabilityKind = "SCHEDULE_REMINDER"
|
||||
CapabilityKindVerifyFact CapabilityKind = "VERIFY_FACT"
|
||||
)
|
||||
|
||||
// Binding is Capability-owned correctness state. SecretRef is an opaque
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package cognitive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
var ErrInvalidContextBudget = errors.New("context budget must be positive")
|
||||
|
||||
type ContextCandidate struct {
|
||||
Excerpt ContextExcerpt
|
||||
Relevance int
|
||||
}
|
||||
|
||||
type ContextCompileInput struct {
|
||||
Candidates []ContextCandidate
|
||||
DataPolicy capability.DataPolicy
|
||||
MaxBytes int
|
||||
}
|
||||
|
||||
type ContextOmission struct {
|
||||
ID ContextExcerptID
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ContextBundle struct {
|
||||
Excerpts []ContextExcerpt
|
||||
Omissions []ContextOmission
|
||||
Bytes int
|
||||
}
|
||||
|
||||
// CompileContext deterministically selects permitted, relevant excerpts for a
|
||||
// gateway request. It carries no provider identity and performs no I/O.
|
||||
func CompileContext(input ContextCompileInput) (ContextBundle, error) {
|
||||
if input.MaxBytes <= 0 {
|
||||
return ContextBundle{}, ErrInvalidContextBudget
|
||||
}
|
||||
candidates := append([]ContextCandidate(nil), input.Candidates...)
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Relevance != candidates[j].Relevance {
|
||||
return candidates[i].Relevance > candidates[j].Relevance
|
||||
}
|
||||
return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID)
|
||||
})
|
||||
bundle := ContextBundle{Excerpts: []ContextExcerpt{}, Omissions: []ContextOmission{}}
|
||||
seen := make(map[ContextExcerptID]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
excerpt := candidate.Excerpt
|
||||
if _, ok := seen[excerpt.ID]; ok {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "duplicate"})
|
||||
continue
|
||||
}
|
||||
seen[excerpt.ID] = struct{}{}
|
||||
reason := permittedContextReason(excerpt, input.DataPolicy)
|
||||
if reason != "" {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: reason})
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(excerpt.Text) == "" {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "empty"})
|
||||
continue
|
||||
}
|
||||
bytes := len([]byte(excerpt.Text))
|
||||
if bundle.Bytes+bytes > input.MaxBytes {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "budget"})
|
||||
continue
|
||||
}
|
||||
bundle.Excerpts = append(bundle.Excerpts, excerpt)
|
||||
bundle.Bytes += bytes
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func permittedContextReason(excerpt ContextExcerpt, policy capability.DataPolicy) string {
|
||||
if excerpt.Sensitivity == SensitivitySecret {
|
||||
return "secret"
|
||||
}
|
||||
if excerpt.Sensitivity == SensitivityUnknown {
|
||||
return "unknown_sensitivity"
|
||||
}
|
||||
if policy == capability.DataPolicyExternalMessageOnly {
|
||||
return "policy_message_only"
|
||||
}
|
||||
if policy == capability.DataPolicyExternalMinimizedContext && excerpt.Sensitivity != SensitivityPublic {
|
||||
return "policy_public_only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
@@ -79,6 +80,19 @@ type GatewayAdvisoryRequest struct {
|
||||
MaxOutputBytes int
|
||||
DataPolicy capability.DataPolicy
|
||||
EgressAcknowledged bool
|
||||
Meaning TaskMeaning
|
||||
}
|
||||
|
||||
// TaskMeaning is the trusted semantic summary produced by Core. It is not a
|
||||
// browser-controlled provider selector; the Capability owner still resolves
|
||||
// the actual binding.
|
||||
type TaskMeaning struct {
|
||||
SpeechAct string
|
||||
CapabilityKind capability.CapabilityKind
|
||||
CapabilityHints []capability.CapabilityKind
|
||||
RequestedOutcome string
|
||||
InformationRequestKind interaction.InformationRequestKind
|
||||
ConfidenceMillis int64
|
||||
}
|
||||
|
||||
type GatewayAdvisoryPolicyRequest struct {
|
||||
@@ -97,6 +111,54 @@ type ProviderUsage struct {
|
||||
OutputUnits int64
|
||||
}
|
||||
|
||||
type InterpretationApplicability string
|
||||
|
||||
const (
|
||||
InterpretationApplicable InterpretationApplicability = "APPLICABLE"
|
||||
InterpretationLowConfidence InterpretationApplicability = "LOW_CONFIDENCE"
|
||||
InterpretationMixedIntent InterpretationApplicability = "MIXED_INTENT"
|
||||
InterpretationNeedsBroader InterpretationApplicability = "NEEDS_BROADER_INTERPRETATION"
|
||||
InterpretationClarificationRequired InterpretationApplicability = "CLARIFICATION_REQUIRED"
|
||||
)
|
||||
|
||||
type GatewayInterpretationRequest struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Message string
|
||||
Locale string
|
||||
MaxOutputBytes int
|
||||
DataPolicy capability.DataPolicy
|
||||
DeterministicSpeechAct string
|
||||
DeterministicSecondary string
|
||||
DeterministicConfidence int64
|
||||
DeterministicApplicability InterpretationApplicability
|
||||
RequestedOutcome string
|
||||
ProjectNames []string
|
||||
}
|
||||
|
||||
type GatewayInterpretationCandidate struct {
|
||||
PrimarySpeechAct string `json:"primary_speech_act"`
|
||||
SecondarySpeechAct string `json:"secondary_speech_act,omitempty"`
|
||||
ConfidenceMillis int64 `json:"confidence_millis"`
|
||||
Applicability InterpretationApplicability `json:"applicability"`
|
||||
RequestedOutcome string `json:"requested_outcome,omitempty"`
|
||||
NeedsCognition bool `json:"needs_cognition"`
|
||||
Clarification string `json:"clarification"`
|
||||
SelectionReason string `json:"selection_reason,omitempty"`
|
||||
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
|
||||
}
|
||||
|
||||
type GatewayInterpretationResponse struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
Candidate GatewayInterpretationCandidate
|
||||
FinishReason FinishReason
|
||||
Usage *ProviderUsage
|
||||
}
|
||||
|
||||
type AdvisoryResponse struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
|
||||
type CommitmentID ids.ID
|
||||
type ProspectiveItemID ids.ID
|
||||
type AttentionItemID ids.ID
|
||||
type ProspectiveRevision uint64
|
||||
type AttentionRevision uint64
|
||||
type CommittedActionIntentID ids.ID
|
||||
type CommittedAuthorizedActionID ids.ID
|
||||
type CommittedActionAttemptID ids.ID
|
||||
type HumanResponseID ids.ID
|
||||
type HumanResponseRevision uint64
|
||||
|
||||
type ProspectiveState string
|
||||
|
||||
@@ -26,6 +31,36 @@ const (
|
||||
ProspectiveExpired ProspectiveState = "EXPIRED"
|
||||
)
|
||||
|
||||
type AttentionLevel string
|
||||
|
||||
const (
|
||||
AttentionUrgent AttentionLevel = "URGENT"
|
||||
AttentionActionRequired AttentionLevel = "ACTION_REQUIRED"
|
||||
AttentionDue AttentionLevel = "DUE"
|
||||
AttentionImportantChange AttentionLevel = "IMPORTANT_CHANGE"
|
||||
AttentionFYI AttentionLevel = "FYI"
|
||||
AttentionBackground AttentionLevel = "BACKGROUND"
|
||||
)
|
||||
|
||||
type AttentionState string
|
||||
|
||||
const (
|
||||
AttentionPending AttentionState = "PENDING"
|
||||
AttentionDelivering AttentionState = "DELIVERING"
|
||||
AttentionDelivered AttentionState = "DELIVERED"
|
||||
AttentionResolved AttentionState = "RESOLVED"
|
||||
AttentionSuppressed AttentionState = "SUPPRESSED"
|
||||
AttentionExpired AttentionState = "EXPIRED"
|
||||
AttentionFailed AttentionState = "FAILED"
|
||||
)
|
||||
|
||||
type AttentionSourceKind string
|
||||
|
||||
const (
|
||||
AttentionSourceProspectiveReminder AttentionSourceKind = "PROSPECTIVE_REMINDER"
|
||||
AttentionSourceAgentDelegation AttentionSourceKind = "AGENT_DELEGATION"
|
||||
)
|
||||
|
||||
// ProspectiveItem is canonical future relevance. TriggerAtUnixMillis is an
|
||||
// absolute instant; scheduling engines are derived delivery mechanisms only.
|
||||
type ProspectiveItem struct {
|
||||
@@ -34,11 +69,166 @@ type ProspectiveItem struct {
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
GoalID work.GoalID
|
||||
SourceIntentID string
|
||||
SourceUtteranceID string
|
||||
Summary string
|
||||
TriggerAtUnixMillis int64
|
||||
State ProspectiveState
|
||||
Revision ProspectiveRevision
|
||||
}
|
||||
|
||||
type ScheduleReminderRequest struct {
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceIntentID string
|
||||
SourceUtteranceID string
|
||||
Summary string
|
||||
TriggerAtUnixMillis int64
|
||||
RequestedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ScheduleReminderResult struct {
|
||||
Item ProspectiveItem
|
||||
}
|
||||
|
||||
// AttentionItem is Continuity-owned human-facing relevance state. It can be
|
||||
// rendered by UIs, but it is not a notification receipt and does not itself
|
||||
// grant authority for external mutation.
|
||||
type AttentionItem struct {
|
||||
ID AttentionItemID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceKind AttentionSourceKind
|
||||
SourceProspectiveID ProspectiveItemID
|
||||
SourceExternalRef string
|
||||
Level AttentionLevel
|
||||
State AttentionState
|
||||
Summary string
|
||||
DueAtUnixMillis int64
|
||||
CreatedAtUnixMillis int64
|
||||
DeliveredAtUnixMillis int64
|
||||
ActionIntentID CommittedActionIntentID
|
||||
AuthorizedActionID CommittedAuthorizedActionID
|
||||
ActionAttemptID CommittedActionAttemptID
|
||||
DeliveryOutcome string
|
||||
ResponseID HumanResponseID
|
||||
ResponseUtteranceID string
|
||||
ResponseOperation HumanResponseOperation
|
||||
ResponseOutcome ResponseOutcome
|
||||
RespondedAtUnixMillis int64
|
||||
Revision AttentionRevision
|
||||
}
|
||||
|
||||
type AttentionListResult struct {
|
||||
Items []AttentionItem
|
||||
}
|
||||
|
||||
type TodayItem struct {
|
||||
Title string
|
||||
WhenUnixMillis int64
|
||||
AttentionLevel AttentionLevel
|
||||
AttentionState AttentionState
|
||||
HumanState string
|
||||
DeliveryState string
|
||||
SourceDescription string
|
||||
ResponseHint string
|
||||
}
|
||||
|
||||
type TodayResult struct {
|
||||
Items []TodayItem
|
||||
}
|
||||
|
||||
type HumanResponseOperation string
|
||||
|
||||
const (
|
||||
ResponseAcknowledge HumanResponseOperation = "ACKNOWLEDGE"
|
||||
ResponseComplete HumanResponseOperation = "COMPLETE"
|
||||
ResponseSnooze HumanResponseOperation = "SNOOZE"
|
||||
ResponseCancel HumanResponseOperation = "CANCEL"
|
||||
ResponseDismiss HumanResponseOperation = "DISMISS"
|
||||
ResponseContinue HumanResponseOperation = "CONTINUE"
|
||||
ResponseReject HumanResponseOperation = "REJECT"
|
||||
ResponseUnknown HumanResponseOperation = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResponseTargetKind string
|
||||
|
||||
const (
|
||||
ResponseTargetAttentionItem ResponseTargetKind = "ATTENTION_ITEM"
|
||||
ResponseTargetProspectiveItem ResponseTargetKind = "PROSPECTIVE_ITEM"
|
||||
ResponseTargetWorkTask ResponseTargetKind = "WORK_TASK"
|
||||
ResponseTargetCapabilityGap ResponseTargetKind = "CAPABILITY_GAP"
|
||||
ResponseTargetInteractionContext ResponseTargetKind = "INTERACTION_CONTEXT"
|
||||
ResponseTargetUnknown ResponseTargetKind = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResponseResolutionState string
|
||||
|
||||
const (
|
||||
ResponseResolved ResponseResolutionState = "RESOLVED"
|
||||
ResponseAmbiguous ResponseResolutionState = "AMBIGUOUS"
|
||||
ResponseNoTarget ResponseResolutionState = "NO_TARGET"
|
||||
ResponseNoop ResponseResolutionState = "NOOP"
|
||||
)
|
||||
|
||||
type ResponseOutcome string
|
||||
|
||||
const (
|
||||
ResponseOutcomeUserReportedComplete ResponseOutcome = "USER_REPORTED_COMPLETE"
|
||||
ResponseOutcomeSnoozed ResponseOutcome = "SNOOZED"
|
||||
ResponseOutcomeCancelled ResponseOutcome = "CANCELLED"
|
||||
ResponseOutcomeDismissed ResponseOutcome = "DISMISSED"
|
||||
ResponseOutcomeClarificationNeeded ResponseOutcome = "CLARIFICATION_NEEDED"
|
||||
ResponseOutcomeContinued ResponseOutcome = "CONTINUED"
|
||||
ResponseOutcomeRejected ResponseOutcome = "REJECTED"
|
||||
ResponseOutcomeNoop ResponseOutcome = "NOOP"
|
||||
)
|
||||
|
||||
type HumanResponse struct {
|
||||
ID HumanResponseID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceUtteranceID string
|
||||
SourceIntentID string
|
||||
Operation HumanResponseOperation
|
||||
TargetKind ResponseTargetKind
|
||||
TargetAttentionID AttentionItemID
|
||||
TargetProspectiveID ProspectiveItemID
|
||||
TargetExternalRef string
|
||||
ResolutionState ResponseResolutionState
|
||||
Outcome ResponseOutcome
|
||||
Clarification string
|
||||
SnoozeUntilUnixMillis int64
|
||||
UserReportedNotVerified bool
|
||||
RecordedAtUnixMillis int64
|
||||
Revision HumanResponseRevision
|
||||
}
|
||||
|
||||
type HumanResponseRequest struct {
|
||||
SourceUtteranceID string
|
||||
SourceIntentID string
|
||||
Message string
|
||||
RequestedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type HumanResponseResult struct {
|
||||
Response HumanResponse
|
||||
Handled bool
|
||||
Reply string
|
||||
}
|
||||
|
||||
type CreateAttentionRequest struct {
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
SourceKind AttentionSourceKind
|
||||
SourceProspectiveID ProspectiveItemID
|
||||
SourceExternalRef string
|
||||
Level AttentionLevel
|
||||
Summary string
|
||||
DueAtUnixMillis int64
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// SatisfyDeliveredSubject is the complete serializable operation subject. It
|
||||
// is distinct from a Work TaskTarget, which remains the live justification.
|
||||
type SatisfyDeliveredSubject struct {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package epistemics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrCitationEvidenceMismatch = errors.New("citation evidence does not match source chain")
|
||||
|
||||
type ResearchPlanID string
|
||||
type SourceID string
|
||||
type SourceSnapshotID string
|
||||
type SourceExcerptID string
|
||||
type CitationID string
|
||||
type FreshnessState string
|
||||
type CoverageState string
|
||||
type ConflictState string
|
||||
type EvidenceKind string
|
||||
type ResearchFindingState 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"
|
||||
)
|
||||
|
||||
type ResearchPlan struct {
|
||||
ID ResearchPlanID
|
||||
Question string
|
||||
SourceClasses []string
|
||||
FreshnessPolicy string
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID SourceID
|
||||
Kind string
|
||||
Locator string
|
||||
Title string
|
||||
Publisher string
|
||||
}
|
||||
|
||||
type SourceSnapshot struct {
|
||||
ID SourceSnapshotID
|
||||
SourceID SourceID
|
||||
ContentDigest string
|
||||
CapturedAtUnixMillis int64
|
||||
Freshness FreshnessState
|
||||
}
|
||||
|
||||
type SourceExcerpt struct {
|
||||
ID SourceExcerptID
|
||||
SnapshotID SourceSnapshotID
|
||||
Text string
|
||||
Start int64
|
||||
End int64
|
||||
}
|
||||
|
||||
type Citation struct {
|
||||
ID CitationID
|
||||
ExcerptID SourceExcerptID
|
||||
Evidence EvidenceKind
|
||||
Coverage CoverageState
|
||||
Conflict ConflictState
|
||||
ResolvedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ResearchCandidate is read-only evidence returned by a connector. The
|
||||
// connector supplies the immutable snapshot and exact excerpt; the evaluator
|
||||
// never fetches, rewrites or invents either one.
|
||||
type ResearchCandidate struct {
|
||||
PropositionKey string
|
||||
Statement string
|
||||
Source Source
|
||||
Snapshot SourceSnapshot
|
||||
Excerpt SourceExcerpt
|
||||
Evidence EvidenceKind
|
||||
Coverage CoverageState
|
||||
Conflict ConflictState
|
||||
}
|
||||
|
||||
type ResearchFinding struct {
|
||||
PropositionKey string
|
||||
Statement string
|
||||
Claim Claim
|
||||
Citations []Citation
|
||||
State ResearchFindingState
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ResearchResult struct {
|
||||
PlanID ResearchPlanID
|
||||
Findings []ResearchFinding
|
||||
EvaluatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ResearchEvaluationRequest carries immutable evidence from a read-only
|
||||
// connector to the evaluator. The runtime never fetches or mutates a source
|
||||
// from this contract.
|
||||
type ResearchEvaluationRequest struct {
|
||||
Plan ResearchPlan
|
||||
Candidates []ResearchCandidate
|
||||
}
|
||||
|
||||
type RepositoryResearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
SourceID SourceID
|
||||
Path string
|
||||
Statement string
|
||||
}
|
||||
|
||||
type WebResearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
SourceID SourceID
|
||||
URL string
|
||||
Title string
|
||||
Statement string
|
||||
}
|
||||
|
||||
// ResolveCitation creates evidence only when all identifiers form one exact
|
||||
// source chain. It deliberately does not fetch, infer or rewrite source text.
|
||||
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
|
||||
if source.ID == "" || strings.TrimSpace(source.Locator) == "" || snapshot.SourceID != source.ID || excerpt.SnapshotID != snapshot.ID || excerpt.Validate() != nil || resolvedAtUnixMillis < 1 || evidence == "" || coverage == "" || conflict == "" {
|
||||
return Citation{}, ErrCitationEvidenceMismatch
|
||||
}
|
||||
citation := Citation{ID: CitationID("citation:" + string(excerpt.ID)), ExcerptID: excerpt.ID, Evidence: evidence, Coverage: coverage, Conflict: conflict, ResolvedAtUnixMillis: resolvedAtUnixMillis}
|
||||
return citation, citation.Validate()
|
||||
}
|
||||
|
||||
func (p ResearchPlan) Validate() error {
|
||||
if p.ID == "" || strings.TrimSpace(p.Question) == "" || len(p.SourceClasses) == 0 || strings.TrimSpace(p.FreshnessPolicy) == "" {
|
||||
return errors.New("research plan requires question, source classes and freshness policy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SourceSnapshot) Validate() error {
|
||||
if s.ID == "" || s.SourceID == "" || strings.TrimSpace(s.ContentDigest) == "" || s.CapturedAtUnixMillis < 1 || s.Freshness == "" {
|
||||
return errors.New("source snapshot requires source, digest, capture time and freshness")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e SourceExcerpt) Validate() error {
|
||||
if e.ID == "" || e.SnapshotID == "" || strings.TrimSpace(e.Text) == "" || e.Start < 0 || e.End < e.Start {
|
||||
return errors.New("source excerpt requires snapshot, text and valid offsets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Citation) Validate() error {
|
||||
if c.ID == "" || c.ExcerptID == "" || c.Evidence == "" || c.Coverage == "" || c.Conflict == "" || c.ResolvedAtUnixMillis < 1 {
|
||||
return errors.New("citation requires exact excerpt, evidence, coverage, conflict and resolution time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package epistemics
|
||||
|
||||
type BeliefState string
|
||||
|
||||
const (
|
||||
BeliefProvisional BeliefState = "PROVISIONAL"
|
||||
BeliefConflicted BeliefState = "CONFLICTED"
|
||||
BeliefVerified BeliefState = "VERIFIED"
|
||||
)
|
||||
|
||||
type Observation struct {
|
||||
ID ObservationID
|
||||
SourceSnapshot SourceSnapshotID
|
||||
Excerpt SourceExcerptID
|
||||
Text string
|
||||
ObservedAt int64
|
||||
}
|
||||
|
||||
type Claim struct {
|
||||
ID ClaimID
|
||||
Statement string
|
||||
ObservationID ObservationID
|
||||
CitationID CitationID
|
||||
Verification VerificationState
|
||||
}
|
||||
|
||||
type Belief struct {
|
||||
ID BeliefID
|
||||
ClaimID ClaimID
|
||||
State BeliefState
|
||||
}
|
||||
|
||||
type TruthBundle struct {
|
||||
Source Source
|
||||
Snapshot SourceSnapshot
|
||||
Excerpt SourceExcerpt
|
||||
Observation Observation
|
||||
Claim Claim
|
||||
Belief Belief
|
||||
Citation Citation
|
||||
}
|
||||
@@ -5,15 +5,132 @@ import "git.cin.su/m/Assistant/platform/ids"
|
||||
type ObservationID ids.ID
|
||||
type ClaimID ids.ID
|
||||
type BeliefID ids.ID
|
||||
type SystemExpectationID ids.ID
|
||||
type ExperienceRecordID ids.ID
|
||||
type ReflectionCandidateID ids.ID
|
||||
type VerificationState string
|
||||
type ClaimSourceKind string
|
||||
type EvidenceRef string
|
||||
type ContextRef string
|
||||
type OriginalIntentRef string
|
||||
type SourceUtteranceRef string
|
||||
type HumanResponseRef string
|
||||
type CapabilityImplementationRef string
|
||||
type CapabilityUsageObservationRef string
|
||||
type CapabilityGapRef string
|
||||
type SkillRef string
|
||||
type ExperienceSourceKind string
|
||||
type HelpMethodKind string
|
||||
type OutcomeClass string
|
||||
type ReflectionTriggerKind string
|
||||
type ReflectionCandidateState string
|
||||
|
||||
const (
|
||||
VerificationUnverified VerificationState = "UNVERIFIED"
|
||||
VerificationVerified VerificationState = "VERIFIED"
|
||||
VerificationRejected VerificationState = "REJECTED"
|
||||
VerificationUnknown VerificationState = "UNKNOWN"
|
||||
VerificationNotApplicable VerificationState = "NOT_APPLICABLE"
|
||||
|
||||
ClaimSourceAuthenticatedHuman ClaimSourceKind = "AUTHENTICATED_HUMAN"
|
||||
ClaimSourceTrustedObservation ClaimSourceKind = "TRUSTED_OBSERVATION"
|
||||
|
||||
ExperienceSourceInteraction ExperienceSourceKind = "INTERACTION"
|
||||
ExperienceSourceContinuity ExperienceSourceKind = "CONTINUITY"
|
||||
ExperienceSourceCapability ExperienceSourceKind = "CAPABILITY"
|
||||
ExperienceSourceCognitiveAdvisory ExperienceSourceKind = "COGNITIVE_ADVISORY"
|
||||
|
||||
HelpMethodDeterministicFastPath HelpMethodKind = "DETERMINISTIC_FAST_PATH"
|
||||
HelpMethodCapabilityExecution HelpMethodKind = "CAPABILITY_EXECUTION"
|
||||
HelpMethodCognitiveAdvisory HelpMethodKind = "COGNITIVE_ADVISORY"
|
||||
HelpMethodClarification HelpMethodKind = "CLARIFICATION"
|
||||
HelpMethodFallback HelpMethodKind = "FALLBACK"
|
||||
|
||||
OutcomeSuccess OutcomeClass = "SUCCESS"
|
||||
OutcomePartialSuccess OutcomeClass = "PARTIAL_SUCCESS"
|
||||
OutcomeFailed OutcomeClass = "FAILED"
|
||||
OutcomeUnknown OutcomeClass = "UNKNOWN"
|
||||
OutcomeUserCorrected OutcomeClass = "USER_CORRECTED"
|
||||
OutcomeUnexpected OutcomeClass = "UNEXPECTED_OUTCOME"
|
||||
OutcomeNotApplicable OutcomeClass = "NOT_APPLICABLE"
|
||||
OutcomeLowConfidence OutcomeClass = "LOW_CONFIDENCE"
|
||||
OutcomeOutOfDistribution OutcomeClass = "OUT_OF_DISTRIBUTION"
|
||||
OutcomeConflict OutcomeClass = "CONFLICT"
|
||||
OutcomeStale OutcomeClass = "STALE"
|
||||
|
||||
ReflectionUnexpectedOutcome ReflectionTriggerKind = "UNEXPECTED_OUTCOME"
|
||||
ReflectionFailure ReflectionTriggerKind = "FAILURE"
|
||||
ReflectionUserCorrection ReflectionTriggerKind = "USER_CORRECTION"
|
||||
ReflectionContradiction ReflectionTriggerKind = "CONTRADICTION"
|
||||
ReflectionLowConfidence ReflectionTriggerKind = "LOW_CONFIDENCE"
|
||||
ReflectionRepeatedDegradation ReflectionTriggerKind = "REPEATED_DEGRADATION"
|
||||
ReflectionEnvironmentChange ReflectionTriggerKind = "ENVIRONMENT_CHANGE"
|
||||
ReflectionImplementationChanged ReflectionTriggerKind = "IMPLEMENTATION_CHANGED"
|
||||
ReflectionStaleSkill ReflectionTriggerKind = "STALE_SKILL"
|
||||
ReflectionSampledAudit ReflectionTriggerKind = "SAMPLED_AUDIT"
|
||||
|
||||
ReflectionCandidateOpen ReflectionCandidateState = "OPEN"
|
||||
ReflectionCandidateEvaluating ReflectionCandidateState = "EVALUATING"
|
||||
ReflectionCandidateSuperseded ReflectionCandidateState = "SUPERSEDED"
|
||||
ReflectionCandidateResolved ReflectionCandidateState = "RESOLVED"
|
||||
)
|
||||
|
||||
// SystemExpectation records what Domovoy expected a meaningful help path to
|
||||
// accomplish before the outcome was known. It is evidence context, not a
|
||||
// commitment or verified fact.
|
||||
type SystemExpectation struct {
|
||||
ID SystemExpectationID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
ExpectedOutcome OutcomeClass
|
||||
ExpectedEffect string
|
||||
ConfidenceMillis int64
|
||||
EvidenceBasis []EvidenceRef
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ExperienceRecord is append-only evidence about an interaction/help episode.
|
||||
// It stores typed references to other owners and never copies their canonical
|
||||
// state or grants authority.
|
||||
type ExperienceRecord struct {
|
||||
ID ExperienceRecordID
|
||||
SourceKind ExperienceSourceKind
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
ContextRefs []ContextRef
|
||||
HelpMethod HelpMethodKind
|
||||
HelpMethodRef string
|
||||
CapabilityImplementationRef CapabilityImplementationRef
|
||||
CapabilityUsageObservationRef CapabilityUsageObservationRef
|
||||
CapabilityGapRef CapabilityGapRef
|
||||
SkillRef SkillRef
|
||||
ExpectationID SystemExpectationID
|
||||
ExpectedOutcome OutcomeClass
|
||||
ActualOutcome OutcomeClass
|
||||
OutcomeRef string
|
||||
OutcomeDetail string
|
||||
HumanResponseRef HumanResponseRef
|
||||
CorrectionUtteranceID SourceUtteranceRef
|
||||
VerificationEvidence []EvidenceRef
|
||||
ConfidenceMillis int64
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ReflectionCandidate records that broader reflection may be warranted.
|
||||
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
|
||||
// produce candidates only; it cannot directly rewrite canonical state.
|
||||
type ReflectionCandidate struct {
|
||||
ID ReflectionCandidateID
|
||||
Trigger ReflectionTriggerKind
|
||||
State ReflectionCandidateState
|
||||
SourceExperienceID ExperienceRecordID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
Summary string
|
||||
EvidenceRefs []EvidenceRef
|
||||
RequiresCognition bool
|
||||
RecordedAtUnixMillis int64
|
||||
Revision int64
|
||||
ClaimedBy string
|
||||
ClaimExpiresAtUnixMillis int64
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
)
|
||||
@@ -15,40 +17,59 @@ type ConversationState string
|
||||
type InterpretationState string
|
||||
type InteractionRelationKind string
|
||||
type InterpretationID string
|
||||
type SemanticCandidateID string
|
||||
type ProjectReferenceID string
|
||||
type ProjectReferenceRole string
|
||||
type ClarificationState string
|
||||
type SemanticApplicability string
|
||||
type SemanticCandidateSource string
|
||||
type SemanticCandidateSelectionState string
|
||||
type InformationRequestKind string
|
||||
|
||||
const (
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
ConversationOpen ConversationState = "OPEN"
|
||||
ConversationArchived ConversationState = "ARCHIVED"
|
||||
SpeechActDirective SpeechAct = "DIRECTIVE"
|
||||
SpeechActAssertion SpeechAct = "ASSERTION"
|
||||
SpeechActDecision SpeechAct = "DECISION"
|
||||
SpeechActPreference SpeechAct = "PREFERENCE"
|
||||
SpeechActQuestion SpeechAct = "QUESTION"
|
||||
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
|
||||
SpeechActCorrection SpeechAct = "CORRECTION"
|
||||
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
|
||||
SpeechActConfirmation SpeechAct = "CONFIRMATION"
|
||||
SpeechActRejection SpeechAct = "REJECTION"
|
||||
SpeechActCancellation SpeechAct = "CANCELLATION"
|
||||
SpeechActConversational SpeechAct = "CONVERSATIONAL"
|
||||
SpeechActUnknown SpeechAct = "UNKNOWN"
|
||||
InterpretationRecorded InterpretationState = "RECORDED"
|
||||
InterpretationInterpreted InterpretationState = "INTERPRETED"
|
||||
RelationCorrects InteractionRelationKind = "CORRECTS"
|
||||
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
|
||||
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
|
||||
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
|
||||
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
|
||||
RelationClarifies InteractionRelationKind = "CLARIFIES"
|
||||
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
|
||||
ProjectRoleRelated ProjectReferenceRole = "RELATED"
|
||||
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
|
||||
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
|
||||
ClarificationRequired ClarificationState = "REQUIRED"
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
ConversationOpen ConversationState = "OPEN"
|
||||
ConversationArchived ConversationState = "ARCHIVED"
|
||||
SpeechActDirective SpeechAct = "DIRECTIVE"
|
||||
SpeechActAssertion SpeechAct = "ASSERTION"
|
||||
SpeechActDecision SpeechAct = "DECISION"
|
||||
SpeechActPreference SpeechAct = "PREFERENCE"
|
||||
SpeechActQuestion SpeechAct = "QUESTION"
|
||||
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
|
||||
SpeechActCorrection SpeechAct = "CORRECTION"
|
||||
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
|
||||
SpeechActConfirmation SpeechAct = "CONFIRMATION"
|
||||
SpeechActRejection SpeechAct = "REJECTION"
|
||||
SpeechActCancellation SpeechAct = "CANCELLATION"
|
||||
SpeechActConversational SpeechAct = "CONVERSATIONAL"
|
||||
SpeechActUnknown SpeechAct = "UNKNOWN"
|
||||
InterpretationRecorded InterpretationState = "RECORDED"
|
||||
InterpretationInterpreted InterpretationState = "INTERPRETED"
|
||||
RelationCorrects InteractionRelationKind = "CORRECTS"
|
||||
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
|
||||
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
|
||||
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
|
||||
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
|
||||
RelationClarifies InteractionRelationKind = "CLARIFIES"
|
||||
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
|
||||
ProjectRoleRelated ProjectReferenceRole = "RELATED"
|
||||
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
|
||||
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
|
||||
ClarificationRequired ClarificationState = "REQUIRED"
|
||||
SemanticApplicable SemanticApplicability = "APPLICABLE"
|
||||
SemanticLowConfidence SemanticApplicability = "LOW_CONFIDENCE"
|
||||
SemanticMixedIntent SemanticApplicability = "MIXED_INTENT"
|
||||
SemanticNeedsBroaderInterpret SemanticApplicability = "NEEDS_BROADER_INTERPRETATION"
|
||||
SemanticCandidateClarify SemanticApplicability = "CLARIFICATION_REQUIRED"
|
||||
CandidateSourceDeterministic SemanticCandidateSource = "DETERMINISTIC_V2"
|
||||
CandidateSourceCognitive SemanticCandidateSource = "COGNITIVE_V1"
|
||||
CandidateSelected SemanticCandidateSelectionState = "SELECTED"
|
||||
CandidateObserved SemanticCandidateSelectionState = "OBSERVED"
|
||||
CandidateRejected SemanticCandidateSelectionState = "REJECTED"
|
||||
InformationRequestCapabilityInventory InformationRequestKind = "CAPABILITY_INVENTORY"
|
||||
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
@@ -92,14 +113,35 @@ type UtteranceInterpretation struct {
|
||||
ID InterpretationID
|
||||
UtteranceID HumanUtteranceID
|
||||
SpeechAct SpeechAct
|
||||
SecondarySpeechAct SpeechAct
|
||||
ConfidenceMillis int64
|
||||
Source string
|
||||
AssertionVerification string
|
||||
RequestedOutcome string
|
||||
Clarification ClarificationState
|
||||
NeedsCognition bool
|
||||
Applicability SemanticApplicability
|
||||
SelectionReason string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type SemanticCandidate struct {
|
||||
ID SemanticCandidateID
|
||||
UtteranceID HumanUtteranceID
|
||||
Source SemanticCandidateSource
|
||||
SelectionState SemanticCandidateSelectionState
|
||||
PrimarySpeechAct SpeechAct
|
||||
SecondarySpeechAct SpeechAct
|
||||
ConfidenceMillis int64
|
||||
Applicability SemanticApplicability
|
||||
RequestedOutcome string
|
||||
Clarification ClarificationState
|
||||
NeedsCognition bool
|
||||
SelectionReason string
|
||||
CapabilityHints []string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ProjectReference struct {
|
||||
ID ProjectReferenceID
|
||||
UtteranceID HumanUtteranceID
|
||||
@@ -119,13 +161,26 @@ type ChatMessageRequest struct {
|
||||
}
|
||||
|
||||
type ChatMessageResponse struct {
|
||||
ConversationID ConversationID
|
||||
UtteranceID HumanUtteranceID
|
||||
SpeechAct SpeechAct
|
||||
InterpretationState InterpretationState
|
||||
NeedsCognition bool
|
||||
FallbackText string
|
||||
ActiveProjectName string
|
||||
ProjectProvenance situation.ProjectResolutionProvenance
|
||||
Clarification ClarificationState
|
||||
ConversationID ConversationID
|
||||
UtteranceID HumanUtteranceID
|
||||
OriginalIntentID OriginalHumanIntentID
|
||||
SpeechAct SpeechAct
|
||||
InterpretationState InterpretationState
|
||||
NeedsCognition bool
|
||||
FallbackText string
|
||||
ActiveProjectName string
|
||||
ProjectProvenance situation.ProjectResolutionProvenance
|
||||
Clarification ClarificationState
|
||||
WorkPlanned bool
|
||||
PlanningReply string
|
||||
CapabilityStatus capability.CapabilityResolutionStatus
|
||||
CapabilityKind capability.CapabilityKind
|
||||
CapabilityHints []capability.CapabilityKind
|
||||
RequestedOutcome string
|
||||
InformationRequestKind InformationRequestKind
|
||||
InterpretationConfidenceMillis int64
|
||||
CapabilityGapID capability.CapabilityGapID
|
||||
DelegationID string
|
||||
DelegationState string
|
||||
Reminder *continuity.ProspectiveItem
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ type SecretPurpose string
|
||||
|
||||
const (
|
||||
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
|
||||
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
|
||||
)
|
||||
|
||||
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
|
||||
type RuntimeConsumer string
|
||||
|
||||
const (
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
|
||||
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
|
||||
RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker"
|
||||
)
|
||||
|
||||
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
|
||||
// AgentExecutionRequest is the serializable hand-off from the durable worker
|
||||
// to an executor. The bridge receives the already-authorized dispatch context;
|
||||
// it does not receive database credentials or ambient authority.
|
||||
type AgentExecutionRequest struct {
|
||||
Intent AgentDispatchIntent `json:"intent"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
FencingToken AgentFencingToken `json:"fencing_token"`
|
||||
LeaseExpiresAt time.Time `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type AgentExecutionState string
|
||||
|
||||
const (
|
||||
AgentExecutionCompleted AgentExecutionState = "COMPLETED"
|
||||
AgentExecutionWaiting AgentExecutionState = "WAITING"
|
||||
AgentExecutionFailed AgentExecutionState = "FAILED"
|
||||
)
|
||||
|
||||
// AgentExecutionResult is an acknowledgement of the external execution
|
||||
// attempt. The worker persists it through the work owner before completing a
|
||||
// dispatch, so a bridge timeout never becomes an unrecorded success.
|
||||
type AgentExecutionResult struct {
|
||||
State AgentExecutionState `json:"state"`
|
||||
Summary string `json:"summary"`
|
||||
OutcomeRef string `json:"outcome_ref"`
|
||||
ArtifactRefs []string `json:"artifact_refs"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package work
|
||||
|
||||
type AgentDispatchQueueState string
|
||||
|
||||
const (
|
||||
AgentDispatchQueued AgentDispatchQueueState = "QUEUED"
|
||||
AgentDispatchClaimed AgentDispatchQueueState = "CLAIMED"
|
||||
AgentDispatchCompleted AgentDispatchQueueState = "COMPLETED"
|
||||
AgentDispatchFailed AgentDispatchQueueState = "FAILED"
|
||||
)
|
||||
|
||||
type AgentDispatchQueueItem struct {
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
State AgentDispatchQueueState `json:"state"`
|
||||
AvailableAtUnixMillis int64 `json:"available_at_unix_millis"`
|
||||
Attempts int `json:"attempts"`
|
||||
ClaimedBy string `json:"claimed_by"`
|
||||
UpdatedAtUnixMillis int64 `json:"updated_at_unix_millis"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user