DEV-000061 checkpoint interaction chat foundation
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
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))
|
||||
return &InteractionRepository{p}, e
|
||||
}
|
||||
func (r *InteractionRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *InteractionRepository) CreateConversation(ctx context.Context, c interaction.Conversation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO interaction.conversations(conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(conversation_id) DO NOTHING`, c.ID, c.ActorID, c.PrincipalID, c.Channel, c.State, c.CreatedAtUnixMillis, c.UpdatedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *InteractionRepository) AppendUtterance(ctx context.Context, c interaction.Conversation, u interaction.HumanUtterance, intent interaction.OriginalHumanIntent, 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.conversations(conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(conversation_id) DO UPDATE SET updated_at_unix_millis=EXCLUDED.updated_at_unix_millis`, c.ID, c.ActorID, c.PrincipalID, c.Channel, c.State, c.CreatedAtUnixMillis, c.UpdatedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.utterances(utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, u.ID, u.ConversationID, u.ActorID, u.PrincipalID, u.Channel, u.ReceivedAtUnixMillis, u.CorrelationID, u.Content, u.ContentRef, u.SpeechAct, u.InterpretationState, u.CorrectsUtteranceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.original_human_intents(intent_id,utterance_id,recorded_at_unix_millis,text) VALUES($1,$2,$3,$4)`, intent.ID, intent.UtteranceID, intent.RecordedAtUnixMillis, intent.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, relation := range relations {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO interaction.relations(relation_id,from_utterance_id,to_utterance_id,kind,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5)`, relation.ID, relation.FromUtteranceID, relation.ToUtteranceID, relation.Kind, relation.RecordedAtUnixMillis); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
func (r *InteractionRepository) Conversation(ctx context.Context, id interaction.ConversationID) (interaction.Conversation, error) {
|
||||
var c interaction.Conversation
|
||||
e := r.pool.QueryRow(ctx, `SELECT conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis FROM interaction.conversations WHERE conversation_id=$1`, id).Scan(&c.ID, &c.ActorID, &c.PrincipalID, &c.Channel, &c.State, &c.CreatedAtUnixMillis, &c.UpdatedAtUnixMillis)
|
||||
return c, e
|
||||
}
|
||||
func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.ConversationID) ([]interaction.HumanUtterance, error) {
|
||||
rows, e := r.pool.Query(ctx, `SELECT utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id FROM interaction.utterances WHERE conversation_id=$1 ORDER BY received_at_unix_millis,utterance_id`, id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []interaction.HumanUtterance
|
||||
for rows.Next() {
|
||||
var u interaction.HumanUtterance
|
||||
if e = rows.Scan(&u.ID, &u.ConversationID, &u.ActorID, &u.PrincipalID, &u.Channel, &u.ReceivedAtUnixMillis, &u.CorrelationID, &u.Content, &u.ContentRef, &u.SpeechAct, &u.InterpretationState, &u.CorrectsUtteranceID); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0009 — Human Interaction canonical owner
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-17
|
||||
Accepted under: DEV-000061 rev.1A Creator decision
|
||||
|
||||
## Decision
|
||||
|
||||
`interaction` is the narrow canonical owner for `Conversation`, `HumanUtterance`, typed `SpeechAct`, immutable/versioned `OriginalHumanIntent`, interpretation revisions, correction relations, and interaction provenance. It is transport-neutral; `WEB_CHAT` is its first channel.
|
||||
|
||||
Interaction stores actor/session/identity-assurance references but does not own authentication or identity truth. It does not own Claims, Evidence, Observations, Beliefs, Work state, Authority, Commit, provider selection, model state, generic memory, or general Artifacts. Assertions may be proposed to Epistemics but never become verified fact automatically. Directives remain non-executable proposals and must use existing Authority/Commit paths.
|
||||
|
||||
Raw content is an immutable Interaction-owned bounded payload in V1; `ContentRef` permits a later Artifact migration without historical rewrite. Conversation only references Situation/Work context and cannot contain their state. Corrections append a new utterance/relation; historical messages are never rewritten.
|
||||
|
||||
Questions may use only the provider-neutral Cognitive Gateway/Router. Cognitive output is advisory and cannot mutate canonical state. Interaction has no provider-specific import or credential.
|
||||
|
||||
## Consequences
|
||||
|
||||
A dedicated `interaction` schema/role and typed contracts are added. No cross-owner SQL join, FK, direct write, privilege escalation, provider activation, or automatic action is introduced.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-0010 - CredentialRef Secret Resolution
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-17
|
||||
Accepted under: DEV-000061 rev.2 Creator scope decision
|
||||
|
||||
## Decision
|
||||
|
||||
Long-lived runtime credentials are represented in canonical configuration as typed `CredentialRef` metadata, never as plaintext secret values. A trusted runtime resolves a `CredentialRef` through a provider-neutral Secret Resolver into a secret value stored in a protected Secret Backend.
|
||||
|
||||
The V1 backend is the existing protected local secret storage exposed to trusted runtimes as files under an explicitly configured root such as `/run/secrets`. This is bootstrap delivery, not canonical live configuration. The resolver is technical platform infrastructure and owns no domain meaning, provider state, model state, authority, or Interaction semantics.
|
||||
|
||||
Docker environment variables may carry minimal bootstrap references, such as a credential reference id or an allowed secret file path, but Docker environment is not the long-term canonical configuration store for Domovoy. Operational concepts such as provider endpoints, model bindings, routing configuration, health, project settings, capability configuration, and runtime policy must remain movable toward canonical registries without redeploy.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Plaintext secrets, API keys, tokens and passwords remain forbidden in source, task packets, logs, tests and ordinary environment configuration.
|
||||
- Secret-file references are allowed only through typed resolver policy and fail closed outside the protected backend root.
|
||||
- Canonical owners store `CredentialRef` metadata and policy only; the secret value stays in the Secret Backend.
|
||||
- The resolver is not Interaction-specific and must be reusable by other trusted runtime consumers.
|
||||
- Cognitive models receive no plaintext secret unless a future explicitly authorized operation requires it.
|
||||
|
||||
## Consequences
|
||||
|
||||
DEV-000061 may connect `interaction_role` by creating a CredentialRef and resolving it from the protected V1 local backend. This adds a minimal `platform/secrets` mechanism and typed `contracts/secrets` contract without introducing Vault, Kubernetes secrets, HSM, distributed replication, secret management UI, automatic rotation, PKI, or cluster scheduling.
|
||||
|
||||
@@ -141,6 +141,13 @@
|
||||
"may_depend_on": [
|
||||
"contracts.identity"
|
||||
]
|
||||
},
|
||||
"interaction": {
|
||||
"id": "mod.interaction",
|
||||
"owns": ["Conversation", "HumanUtterance", "UtteranceInterpretation", "OriginalHumanIntent", "InteractionRelation"],
|
||||
"database_schema": "interaction",
|
||||
"boundary_class": "remote_capable",
|
||||
"may_depend_on": ["contracts.identity", "contracts.situation", "contracts.cognitive", "platform.ids", "platform.events"]
|
||||
}
|
||||
},
|
||||
"platform": {
|
||||
@@ -150,7 +157,8 @@
|
||||
"platform.outbox",
|
||||
"platform.inbox",
|
||||
"platform.telemetry",
|
||||
"platform.errors"
|
||||
"platform.errors",
|
||||
"platform.secrets"
|
||||
],
|
||||
"rule": "platform owns mechanisms, never domain meaning"
|
||||
},
|
||||
@@ -162,7 +170,8 @@
|
||||
"work",
|
||||
"epistemics",
|
||||
"capability",
|
||||
"artifact"
|
||||
"artifact",
|
||||
"interaction"
|
||||
]
|
||||
},
|
||||
"continuity_runtime": {
|
||||
@@ -197,7 +206,8 @@
|
||||
"continuity": "continuity_role",
|
||||
"capability": "capability_role",
|
||||
"commit": "commit_role",
|
||||
"artifact": "artifact_role"
|
||||
"artifact": "artifact_role",
|
||||
"interaction": "interaction_role"
|
||||
},
|
||||
"cross_owner_fk": "forbidden_by_default",
|
||||
"cross_owner_write": "forbidden",
|
||||
|
||||
@@ -68,6 +68,13 @@ modules:
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity]
|
||||
|
||||
interaction:
|
||||
id: mod.interaction
|
||||
owns: [Conversation, HumanUtterance, UtteranceInterpretation, OriginalHumanIntent, InteractionRelation]
|
||||
database_schema: interaction
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.situation, contracts.cognitive, platform.ids, platform.events]
|
||||
|
||||
platform:
|
||||
allowed_packages:
|
||||
- platform.ids
|
||||
@@ -76,11 +83,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:
|
||||
@@ -102,6 +110,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
|
||||
|
||||
@@ -3,16 +3,22 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
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"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -63,6 +69,19 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer identityRepository.Close()
|
||||
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
interactionUser := os.Getenv("INTERACTION_POSTGRES_USER")
|
||||
if interactionUser == "" {
|
||||
interactionUser = "interaction_role"
|
||||
}
|
||||
interactionRepository, err := postgres.OpenInteraction(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: interactionUser, Password: interactionPassword})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer interactionRepository.Close()
|
||||
mux := http.NewServeMux()
|
||||
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
||||
@@ -141,6 +160,64 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request interaction.ChatMessageRequest
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, interactiondomain.MaxHumanUtteranceBytes+1024))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
http.Error(w, "invalid chat message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
var conversation interaction.Conversation
|
||||
if request.ConversationID == "" {
|
||||
conversation, err = interactiondomain.NewConversation(session.ActorID, session.PrincipalID, now)
|
||||
if err != nil {
|
||||
http.Error(w, "conversation unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
conversation, err = interactionRepository.Conversation(r.Context(), request.ConversationID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "conversation not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "conversation unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if conversation.ActorID != session.ActorID || conversation.PrincipalID != session.PrincipalID {
|
||||
http.Error(w, "conversation denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conversation.UpdatedAtUnixMillis = now.UnixMilli()
|
||||
}
|
||||
utterance, intent, relations, err := interactiondomain.NewHumanUtterance(conversation, request, now)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid chat message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
|
||||
http.Error(w, "save chat message", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response := interaction.ChatMessageResponse{
|
||||
ConversationID: conversation.ID,
|
||||
UtteranceID: utterance.ID,
|
||||
SpeechAct: utterance.SpeechAct,
|
||||
InterpretationState: utterance.InterpretationState,
|
||||
NeedsCognition: interactiondomain.NeedsCognition(utterance.SpeechAct),
|
||||
FallbackText: interactiondomain.FallbackText(utterance.SpeechAct),
|
||||
}
|
||||
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/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 {
|
||||
@@ -498,3 +575,31 @@ func readRequiredSecret(name string) (string, error) {
|
||||
}
|
||||
return direct, nil
|
||||
}
|
||||
|
||||
func readCredentialRefSecret(name, refName string) (string, error) {
|
||||
if _, directSet := os.LookupEnv(name); directSet {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name)
|
||||
}
|
||||
fileName := os.Getenv(name + "_FILE")
|
||||
if fileName == "" {
|
||||
return "", fmt.Errorf("%s_FILE is required", name)
|
||||
}
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("%s is required", 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.RuntimeConsumerCore,
|
||||
Path: fileName,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestChatPageServedWithoutInternalIDs(t *testing.T) {
|
||||
handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/chat", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
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"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("/chat leaks internal field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
var cognitiveRequest cognitive.GatewayAdvisoryRequest
|
||||
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":
|
||||
var request interaction.ChatMessageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Message != "Кто ты?" || request.ConversationID != "" {
|
||||
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"})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
continuity := httptest.NewServer(http.NotFoundHandler())
|
||||
defer continuity.Close()
|
||||
commit := httptest.NewServer(http.NotFoundHandler())
|
||||
defer commit.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" {
|
||||
t.Fatalf("unexpected cognitive route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&cognitiveRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitiveRequest.RequestID, AdvisoryText: "Я Домовой.", FinishReason: cognitive.FinishCompleted, ReferencedExcerptIDs: []cognitive.ContextExcerptID{}})
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
|
||||
handler, err := newHandler(core.URL, continuity.URL, commit.URL, core.Client(), cognitiveRuntime.URL)
|
||||
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("expected status 200, got %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.ConversationID != "conversation-1" || response.SpeechAct != string(interaction.SpeechActQuestion) {
|
||||
t.Fatalf("unexpected chat response: %#v", response)
|
||||
}
|
||||
if cognitiveRequest.DataPolicy != capability.DataPolicyLocalOnly || cognitiveRequest.EgressAcknowledged {
|
||||
t.Fatalf("unexpected cognitive policy: %#v", cognitiveRequest)
|
||||
}
|
||||
if cognitiveRequest.ActorID != "actor-1" || cognitiveRequest.PrincipalID != "principal-1" || cognitiveRequest.ScopeID != "domovoy-global-scope" || cognitiveRequest.Message != "Кто ты?" {
|
||||
t.Fatalf("unexpected trusted gateway request: %#v", cognitiveRequest)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"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"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
@@ -98,6 +99,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleAdvisory(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/chat/messages" {
|
||||
g.handleChatMessage(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -108,6 +113,11 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if r.URL.Path == "/chat" {
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/chat.html"
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
g.static.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -124,6 +134,21 @@ type advisoryPolicy struct {
|
||||
Disclosure string
|
||||
}
|
||||
|
||||
type browserChatRequest struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Message string `json:"message"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
CorrectsUtteranceID string `json:"corrects_utterance_id"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
@@ -249,6 +274,68 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(advisory)
|
||||
}
|
||||
|
||||
func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var browserRequest browserChatRequest
|
||||
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || strings.TrimSpace(browserRequest.Message) == "" || len(browserRequest.Message) > 16*1024 {
|
||||
http.Error(w, "invalid chat message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
coreRequest := interaction.ChatMessageRequest{ConversationID: interaction.ConversationID(browserRequest.ConversationID), Message: browserRequest.Message, CorrelationID: browserRequest.CorrelationID, CorrectsUtteranceID: interaction.HumanUtteranceID(browserRequest.CorrectsUtteranceID)}
|
||||
payload, err := json.Marshal(coreRequest)
|
||||
if err != nil {
|
||||
http.Error(w, "chat unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var saved interaction.ChatMessageResponse
|
||||
status, err = g.postTrustedJSON(r, g.core, "/v1/creator/chat/messages", authorization, payload, &saved)
|
||||
if err != nil {
|
||||
http.Error(w, "chat unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusCreated {
|
||||
http.Error(w, "chat unavailable", status)
|
||||
return
|
||||
}
|
||||
reply := saved.FallbackText
|
||||
cognition := "not_requested"
|
||||
if saved.NeedsCognition {
|
||||
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}
|
||||
payload, err = json.Marshal(trusted)
|
||||
if err == nil {
|
||||
var advisory cognitive.GatewayAdvisoryResponse
|
||||
status, err = g.postTrustedJSON(r, g.cognitive, "/v1/advisories", "", payload, &advisory)
|
||||
if err == nil && status == http.StatusOK && validAdvisoryResponse(advisory, trusted) {
|
||||
cognition = string(advisory.FinishReason)
|
||||
if advisory.FinishReason == cognitive.FinishCompleted {
|
||||
reply = advisory.AdvisoryText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool {
|
||||
if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || len(response.ReferencedExcerptIDs) != 0 {
|
||||
return false
|
||||
@@ -290,6 +377,32 @@ func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQu
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (g *gateway) postTrustedJSON(r *http.Request, upstream *url.URL, path, authorization string, payload []byte, destination any) (int, error) {
|
||||
target := *upstream
|
||||
target.Path = path
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if authorization != "" {
|
||||
request.Header.Set("Authorization", authorization)
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode > 299 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes))
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
if err := decodeStrictJSON(response.Body, maxResponseBytes, destination); err != nil {
|
||||
return response.StatusCode, err
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(reader io.Reader, limit int64, destination any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, limit+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
:root {
|
||||
--ink: #e9eee7;
|
||||
--muted: #93a096;
|
||||
--base: #0b100e;
|
||||
--panel: #101714;
|
||||
--line: #2a352f;
|
||||
--signal: #c9a85f;
|
||||
--bad: #de816f;
|
||||
--good: #78c6a3;
|
||||
--radius: 3px;
|
||||
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--mono: "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
|
||||
.chat-shell { min-height: 100vh; display: grid; }
|
||||
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
|
||||
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
|
||||
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
|
||||
input { height: 48px; padding: 0 12px; }
|
||||
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
#login-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-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
.chat-header span { margin-left: auto; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.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; }
|
||||
.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 button { height: 48px; align-self: end; }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
||||
@media (max-width: 640px) {
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.composer button { width: 100%; }
|
||||
.chat-header span { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
const loginView = document.querySelector("#login-view");
|
||||
const chatView = document.querySelector("#chat-view");
|
||||
const loginForm = document.querySelector("#login-form");
|
||||
const loginError = document.querySelector("#login-error");
|
||||
const messages = document.querySelector("#messages");
|
||||
const chatForm = document.querySelector("#chat-form");
|
||||
const messageInput = document.querySelector("#message");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
|
||||
let token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||
|
||||
function showChat() {
|
||||
loginView.hidden = true;
|
||||
chatView.hidden = false;
|
||||
if (!messages.children.length) {
|
||||
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
|
||||
}
|
||||
messageInput.focus();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
chatView.hidden = true;
|
||||
loginView.hidden = false;
|
||||
}
|
||||
|
||||
function appendMessage(kind, text, meta = "") {
|
||||
const node = document.createElement("div");
|
||||
node.className = `message ${kind}`;
|
||||
node.textContent = text;
|
||||
if (meta) {
|
||||
const small = document.createElement("small");
|
||||
small.textContent = meta;
|
||||
node.appendChild(small);
|
||||
}
|
||||
messages.appendChild(node);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
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());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
loginError.textContent = "";
|
||||
const username = document.querySelector("#username").value;
|
||||
const password = document.querySelector("#password").value;
|
||||
try {
|
||||
const result = await api("/api/session", {method: "POST", body: JSON.stringify({username, password})});
|
||||
token = result.Token || result.token;
|
||||
sessionStorage.setItem("domovoy.chat.token", token);
|
||||
showChat();
|
||||
} catch {
|
||||
loginError.textContent = "Вход не выполнен";
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", `${result.speech_act}; cognition=${result.cognition}`);
|
||||
} catch {
|
||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
messageInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
token = "";
|
||||
conversationID = "";
|
||||
sessionStorage.removeItem("domovoy.chat.token");
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
if (token) {
|
||||
showChat();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#101714">
|
||||
<title>Домовой - чат</title>
|
||||
<link rel="stylesheet" href="/assets/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="chat-shell">
|
||||
<section id="login-view" class="login-panel">
|
||||
<div class="brand-mark" aria-hidden="true">Д</div>
|
||||
<h1>Домовой</h1>
|
||||
<form id="login-form" class="login-form">
|
||||
<label for="username">Логин</label>
|
||||
<input id="username" autocomplete="username" required autofocus>
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Войти</button>
|
||||
<p id="login-error" role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header">
|
||||
<a href="/" aria-label="Консоль создателя">Домовой</a>
|
||||
<span id="session-state">сессия активна</span>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
package interaction
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/identity"
|
||||
|
||||
type ConversationID string
|
||||
type HumanUtteranceID string
|
||||
type OriginalHumanIntentID string
|
||||
type InteractionRelationID string
|
||||
type SpeechAct string
|
||||
type Channel string
|
||||
type ConversationState string
|
||||
type InterpretationState string
|
||||
type InteractionRelationKind 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"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
ID ConversationID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
Channel Channel
|
||||
State ConversationState
|
||||
CreatedAtUnixMillis int64
|
||||
UpdatedAtUnixMillis int64
|
||||
}
|
||||
type HumanUtterance struct {
|
||||
ID HumanUtteranceID
|
||||
ConversationID ConversationID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
Channel Channel
|
||||
ReceivedAtUnixMillis int64
|
||||
CorrelationID string
|
||||
Content string
|
||||
ContentRef string
|
||||
SpeechAct SpeechAct
|
||||
InterpretationState InterpretationState
|
||||
CorrectsUtteranceID HumanUtteranceID
|
||||
}
|
||||
type OriginalHumanIntent struct {
|
||||
ID OriginalHumanIntentID
|
||||
UtteranceID HumanUtteranceID
|
||||
RecordedAtUnixMillis int64
|
||||
Text string
|
||||
}
|
||||
type InteractionRelation struct {
|
||||
ID InteractionRelationID
|
||||
FromUtteranceID HumanUtteranceID
|
||||
ToUtteranceID HumanUtteranceID
|
||||
Kind InteractionRelationKind
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ChatMessageRequest struct {
|
||||
ConversationID ConversationID
|
||||
Message string
|
||||
CorrelationID string
|
||||
CorrectsUtteranceID HumanUtteranceID
|
||||
}
|
||||
|
||||
type ChatMessageResponse struct {
|
||||
ConversationID ConversationID
|
||||
UtteranceID HumanUtteranceID
|
||||
SpeechAct SpeechAct
|
||||
InterpretationState InterpretationState
|
||||
NeedsCognition bool
|
||||
FallbackText string
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package secrets defines provider-neutral references to protected runtime
|
||||
// credentials. It never contains plaintext secret values.
|
||||
package secrets
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package secrets
|
||||
|
||||
// CredentialRef identifies a secret through canonical metadata without exposing
|
||||
// the underlying secret value.
|
||||
type CredentialRef string
|
||||
|
||||
// SecretPurpose is a typed, non-secret description of why a credential exists.
|
||||
type SecretPurpose string
|
||||
|
||||
const (
|
||||
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
|
||||
)
|
||||
|
||||
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
|
||||
type RuntimeConsumer string
|
||||
|
||||
const (
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
)
|
||||
|
||||
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
|
||||
// file backend. Path is a reference, not a secret value.
|
||||
type SecretFileRef struct {
|
||||
CredentialRef CredentialRef
|
||||
Purpose SecretPurpose
|
||||
Consumer RuntimeConsumer
|
||||
Path string
|
||||
}
|
||||
@@ -27,6 +27,9 @@ REVOKE ALL ON SCHEMA commit FROM PUBLIC;
|
||||
DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'artifact_role') THEN CREATE ROLE artifact_role LOGIN; END IF; END $$;
|
||||
CREATE SCHEMA IF NOT EXISTS artifact AUTHORIZATION artifact_role;
|
||||
REVOKE ALL ON SCHEMA artifact FROM PUBLIC;
|
||||
DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'interaction_role') THEN CREATE ROLE interaction_role LOGIN; END IF; END $$;
|
||||
CREATE SCHEMA IF NOT EXISTS interaction AUTHORIZATION interaction_role;
|
||||
REVOKE ALL ON SCHEMA interaction FROM PUBLIC;
|
||||
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.actors (
|
||||
@@ -304,4 +307,52 @@ CREATE TABLE IF NOT EXISTS continuity.action_receipts (
|
||||
CHECK (expected_revision > 0), CHECK (result_revision > expected_revision)
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE interaction_role;
|
||||
CREATE TABLE IF NOT EXISTS interaction.conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
updated_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (channel = 'WEB_CHAT'),
|
||||
CHECK (state IN ('OPEN','ARCHIVED'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction.utterances (
|
||||
utterance_id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
received_at_unix_millis BIGINT NOT NULL,
|
||||
correlation_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_ref TEXT NOT NULL,
|
||||
speech_act TEXT NOT NULL,
|
||||
interpretation_state TEXT NOT NULL,
|
||||
corrects_utterance_id TEXT NOT NULL DEFAULT '',
|
||||
CHECK (channel = 'WEB_CHAT'),
|
||||
CHECK (content <> ''),
|
||||
CHECK (content_ref <> ''),
|
||||
CHECK (speech_act IN ('DIRECTIVE','ASSERTION','DECISION','PREFERENCE','QUESTION','HYPOTHESIS','CORRECTION','REQUEST_TO_INVESTIGATE','CONFIRMATION','REJECTION','CANCELLATION','CONVERSATIONAL','UNKNOWN')),
|
||||
CHECK (interpretation_state IN ('RECORDED','INTERPRETED'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction.original_human_intents (
|
||||
intent_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
CHECK (text <> '')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction.relations (
|
||||
relation_id TEXT PRIMARY KEY,
|
||||
from_utterance_id TEXT NOT NULL,
|
||||
to_utterance_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (kind IN ('CORRECTS','RESPONDS_TO','REQUESTS_COGNITION','COGNITION_FALLBACK'))
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'interaction_role') THEN
|
||||
CREATE ROLE interaction_role LOGIN;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS interaction AUTHORIZATION interaction_role;
|
||||
REVOKE ALL ON SCHEMA interaction FROM PUBLIC;
|
||||
|
||||
SET ROLE interaction_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
updated_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (channel = 'WEB_CHAT'),
|
||||
CHECK (state IN ('OPEN','ARCHIVED'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.utterances (
|
||||
utterance_id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
received_at_unix_millis BIGINT NOT NULL,
|
||||
correlation_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_ref TEXT NOT NULL,
|
||||
speech_act TEXT NOT NULL,
|
||||
interpretation_state TEXT NOT NULL,
|
||||
corrects_utterance_id TEXT NOT NULL DEFAULT '',
|
||||
CHECK (channel = 'WEB_CHAT'),
|
||||
CHECK (content <> ''),
|
||||
CHECK (content_ref <> ''),
|
||||
CHECK (speech_act IN ('DIRECTIVE','ASSERTION','DECISION','PREFERENCE','QUESTION','HYPOTHESIS','CORRECTION','REQUEST_TO_INVESTIGATE','CONFIRMATION','REJECTION','CANCELLATION','CONVERSATIONAL','UNKNOWN')),
|
||||
CHECK (interpretation_state IN ('RECORDED','INTERPRETED'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.original_human_intents (
|
||||
intent_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
CHECK (text <> '')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.relations (
|
||||
relation_id TEXT PRIMARY KEY,
|
||||
from_utterance_id TEXT NOT NULL,
|
||||
to_utterance_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (kind IN ('CORRECTS','RESPONDS_TO','REQUESTS_COGNITION','COGNITION_FALLBACK'))
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -17,8 +17,10 @@ services:
|
||||
WORK_DB_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_DB_PASSWORD: ${CAPABILITY_DB_PASSWORD}
|
||||
COMMIT_DB_PASSWORD: ${COMMIT_DB_PASSWORD}
|
||||
INTERACTION_DB_PASSWORD_FILE: /run/secrets/interaction_db_password
|
||||
volumes:
|
||||
- assistant_pg:/var/lib/postgresql
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_SUPERUSER} -d ${POSTGRES_DB:-assistant}"]
|
||||
interval: 10s
|
||||
@@ -70,6 +72,10 @@ services:
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
|
||||
INTERACTION_POSTGRES_USER: interaction_role
|
||||
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
|
||||
INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
|
||||
@@ -21,3 +21,10 @@ SQL
|
||||
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v commit_password="$COMMIT_DB_PASSWORD" <<'SQL'
|
||||
ALTER ROLE commit_role PASSWORD :'commit_password';
|
||||
SQL
|
||||
if [ "${INTERACTION_DB_PASSWORD_FILE:-}" != "" ]; then
|
||||
INTERACTION_DB_PASSWORD="$(tr -d '\r\n' < "$INTERACTION_DB_PASSWORD_FILE")"
|
||||
fi
|
||||
: "${INTERACTION_DB_PASSWORD:?INTERACTION_DB_PASSWORD or INTERACTION_DB_PASSWORD_FILE is required}"
|
||||
psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v interaction_password="$INTERACTION_DB_PASSWORD" <<'SQL'
|
||||
ALTER ROLE interaction_role PASSWORD :'interaction_password';
|
||||
SQL
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000060-llm-free-provider-env-audit.json",
|
||||
"task_id": "DEV-000060",
|
||||
"task_revision": 1
|
||||
"task_file": "dev/tasks/DEV-000061-human-interaction-foundation.json",
|
||||
"task_id": "DEV-000061",
|
||||
"task_revision": 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"task_id": "DEV-000061",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ADR-0009+ADR-0010",
|
||||
"base_repo_revision": "e4b6121dc08041f1d4dc00aa042714d0fd933aa0",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add the Interaction canonical owner, bounded authenticated Web Chat V1 foundation, and the minimal provider-neutral CredentialRef secret resolution prerequisite.",
|
||||
"requirement_ids": [
|
||||
"INV-OWN-001",
|
||||
"INV-EPI-001",
|
||||
"ADR-0009",
|
||||
"ADR-0010"
|
||||
],
|
||||
"allowed_change_scope": {
|
||||
"modules": [
|
||||
"interaction",
|
||||
"identity",
|
||||
"epistemics",
|
||||
"capability",
|
||||
"platform"
|
||||
],
|
||||
"paths": [
|
||||
"architecture/adrs/ADR-0009-human-interaction-owner.md",
|
||||
"architecture/adrs/ADR-0010-credential-ref-secret-resolution.md",
|
||||
"architecture/architecture-registry.yaml",
|
||||
"architecture/architecture-registry.json",
|
||||
"database/**",
|
||||
"contracts/interaction/**",
|
||||
"contracts/secrets/**",
|
||||
"modules/interaction/**",
|
||||
"platform/secrets/**",
|
||||
"adapters/postgres/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/creator-web/**",
|
||||
"deploy/**",
|
||||
"tests/human_interaction/**",
|
||||
"tests/secret_resolution/**",
|
||||
"tests/architecture/**",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000061-human-interaction-foundation.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||
"contracts/identity/**",
|
||||
"contracts/epistemics/**",
|
||||
"contracts/work/**",
|
||||
"contracts/authority/**",
|
||||
"contracts/commit/**",
|
||||
"modules/identity/**",
|
||||
"modules/epistemics/**",
|
||||
"modules/work/**",
|
||||
"modules/authority/**",
|
||||
"modules/commit/**",
|
||||
"cmd/cognitive-worker/**",
|
||||
"security/**",
|
||||
"ci/**",
|
||||
".gitea/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 3,
|
||||
"new_canonical_owners": 1,
|
||||
"new_privileges": 1,
|
||||
"new_third_party_dependencies": 0,
|
||||
"new_provider_adapters": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": [
|
||||
"identity session",
|
||||
"situation focus",
|
||||
"cognitive gateway",
|
||||
"credential ref secret resolution"
|
||||
],
|
||||
"may_change": [
|
||||
"contracts/interaction@v1",
|
||||
"contracts/secrets@v1"
|
||||
]
|
||||
},
|
||||
"semantic_owner": "interaction",
|
||||
"canonical_state": [
|
||||
"Conversation",
|
||||
"HumanUtterance",
|
||||
"UtteranceInterpretation",
|
||||
"OriginalHumanIntent",
|
||||
"InteractionRelation"
|
||||
],
|
||||
"state_transitions": [
|
||||
"utterance RECORDED->INTERPRETED",
|
||||
"conversation OPEN"
|
||||
],
|
||||
"side_effects_and_privileges": [
|
||||
"authenticated human message persistence",
|
||||
"bounded advisory call through existing gateway only",
|
||||
"interaction_role credential resolution through CredentialRef and Secret Resolver"
|
||||
],
|
||||
"persistence_and_migrations": [
|
||||
"interaction schema and owner-local tables",
|
||||
"no cross-owner FK",
|
||||
"CredentialRef metadata only; plaintext secrets remain in protected Secret Backend"
|
||||
],
|
||||
"failure_retry_restart": [
|
||||
"append utterance before interpretation",
|
||||
"cognitive loss produces human-safe unavailable response",
|
||||
"no automatic mutation",
|
||||
"secret resolution fails closed without exposing plaintext"
|
||||
],
|
||||
"assumptions": [
|
||||
"user accepted ADR-0009 decision",
|
||||
"user accepted ADR-0010 decision",
|
||||
"CPU CANARY allowed only through existing flag",
|
||||
"docker-test prohibited",
|
||||
"existing protected local secret storage is the first Secret Backend"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"typed utterance and conversation",
|
||||
"identity assurance separate from truth",
|
||||
"speech acts distinguishable",
|
||||
"correction appends",
|
||||
"no direct provider",
|
||||
"no ACTIVE binding",
|
||||
"CredentialRef resolves only for authorized runtime consumers"
|
||||
],
|
||||
"required_tests": [
|
||||
"go test ./...",
|
||||
"go vet ./...",
|
||||
"architecture",
|
||||
"owner ACL",
|
||||
"LLM-free",
|
||||
"cognitive registry",
|
||||
"human interaction",
|
||||
"secret resolution"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
+12
-1
@@ -1,6 +1,17 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000060 revision 1.
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000061 revision 2.
|
||||
|
||||
## DEV-000061 checkpoint
|
||||
|
||||
- `DEV_000061_STATUS = IN_PROGRESS`
|
||||
- `LAST_COMPLETED = DEV-000061 packet rev.2, ADR-0009, ADR-0010, Interaction registry declaration, CredentialRef file resolver foundation, interaction schema migration/bootstrap, interaction repository, Core Runtime chat persistence endpoint, Creator Web /chat surface and LOCAL_ONLY Cognitive Gateway handoff`
|
||||
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -task dev/tasks/DEV-000061-human-interaction-foundation.json; go run ./cmd/devctl check-architecture`
|
||||
- `ACTIVE_BINDINGS = none`
|
||||
- `CANARY_BINDING = unchanged; not promoted to ACTIVE`
|
||||
- `OPENAI_REAL_CREDENTIAL = not required / not activated`
|
||||
- `NEXT_STEP = production interaction_role delivery: copy protected local interaction_role credential into the existing assistant_core_secrets backend as /run/secrets/interaction_db_password, set/validate PostgreSQL interaction_role password, apply migration, rebuild/deploy affected production services, then run live /chat acceptance`
|
||||
- `REAL_BLOCKERS = none known`
|
||||
|
||||
## Current milestone
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
const MaxHumanUtteranceBytes = 16 << 10
|
||||
|
||||
var (
|
||||
ErrEmptyMessage = errors.New("empty human message")
|
||||
ErrMessageTooLarge = errors.New("human message too large")
|
||||
ErrInvalidPrincipal = errors.New("invalid interaction principal")
|
||||
)
|
||||
|
||||
type Clock func() time.Time
|
||||
|
||||
func NewConversation(actorID identity.ActorID, principalID identity.PrincipalID, now time.Time) (interaction.Conversation, error) {
|
||||
if actorID == "" || principalID == "" {
|
||||
return interaction.Conversation{}, ErrInvalidPrincipal
|
||||
}
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.Conversation{}, err
|
||||
}
|
||||
millis := now.UnixMilli()
|
||||
return interaction.Conversation{
|
||||
ID: interaction.ConversationID(id),
|
||||
ActorID: actorID,
|
||||
PrincipalID: principalID,
|
||||
Channel: interaction.ChannelWebChat,
|
||||
State: interaction.ConversationOpen,
|
||||
CreatedAtUnixMillis: millis,
|
||||
UpdatedAtUnixMillis: millis,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewHumanUtterance(conversation interaction.Conversation, request interaction.ChatMessageRequest, now time.Time) (interaction.HumanUtterance, interaction.OriginalHumanIntent, []interaction.InteractionRelation, error) {
|
||||
message := strings.TrimSpace(request.Message)
|
||||
if message == "" {
|
||||
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, ErrEmptyMessage
|
||||
}
|
||||
if len(message) > MaxHumanUtteranceBytes || !utf8.ValidString(message) {
|
||||
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, ErrMessageTooLarge
|
||||
}
|
||||
utteranceID, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
||||
}
|
||||
intentID, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
||||
}
|
||||
millis := now.UnixMilli()
|
||||
speechAct := ClassifySpeechAct(message, request.CorrectsUtteranceID)
|
||||
utterance := interaction.HumanUtterance{
|
||||
ID: interaction.HumanUtteranceID(utteranceID),
|
||||
ConversationID: conversation.ID,
|
||||
ActorID: conversation.ActorID,
|
||||
PrincipalID: conversation.PrincipalID,
|
||||
Channel: interaction.ChannelWebChat,
|
||||
ReceivedAtUnixMillis: millis,
|
||||
CorrelationID: request.CorrelationID,
|
||||
Content: message,
|
||||
ContentRef: "interaction:utterance:" + string(utteranceID),
|
||||
SpeechAct: speechAct,
|
||||
InterpretationState: interaction.InterpretationInterpreted,
|
||||
CorrectsUtteranceID: request.CorrectsUtteranceID,
|
||||
}
|
||||
intent := interaction.OriginalHumanIntent{
|
||||
ID: interaction.OriginalHumanIntentID(intentID),
|
||||
UtteranceID: utterance.ID,
|
||||
RecordedAtUnixMillis: millis,
|
||||
Text: message,
|
||||
}
|
||||
var relations []interaction.InteractionRelation
|
||||
if request.CorrectsUtteranceID != "" || speechAct == interaction.SpeechActCorrection {
|
||||
relationID, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
||||
}
|
||||
relations = append(relations, interaction.InteractionRelation{
|
||||
ID: interaction.InteractionRelationID(relationID),
|
||||
FromUtteranceID: utterance.ID,
|
||||
ToUtteranceID: request.CorrectsUtteranceID,
|
||||
Kind: interaction.RelationCorrects,
|
||||
RecordedAtUnixMillis: millis,
|
||||
})
|
||||
}
|
||||
return utterance, intent, relations, nil
|
||||
}
|
||||
|
||||
func ClassifySpeechAct(message string, corrects interaction.HumanUtteranceID) interaction.SpeechAct {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
switch {
|
||||
case corrects != "" || strings.Contains(lower, "исправ") || strings.Contains(lower, "не так"):
|
||||
return interaction.SpeechActCorrection
|
||||
case strings.Contains(lower, "?") || strings.HasPrefix(lower, "что ") || strings.HasPrefix(lower, "как ") || strings.HasPrefix(lower, "почему ") || strings.HasPrefix(lower, "кто "):
|
||||
return interaction.SpeechActQuestion
|
||||
case strings.Contains(lower, "проверь") || strings.Contains(lower, "исслед") || strings.Contains(lower, "разбер"):
|
||||
return interaction.SpeechActRequestToInvestigate
|
||||
case strings.Contains(lower, "сделай") || strings.Contains(lower, "запусти") || strings.Contains(lower, "создай") || strings.Contains(lower, "настрой"):
|
||||
return interaction.SpeechActDirective
|
||||
case strings.HasPrefix(lower, "я решил") || strings.Contains(lower, "решение"):
|
||||
return interaction.SpeechActDecision
|
||||
case strings.Contains(lower, "предпочита") || strings.Contains(lower, "лучше"):
|
||||
return interaction.SpeechActPreference
|
||||
case strings.HasPrefix(lower, "думаю") || strings.Contains(lower, "гипотез"):
|
||||
return interaction.SpeechActHypothesis
|
||||
case strings.Contains(lower, "да") || strings.Contains(lower, "подтверж"):
|
||||
return interaction.SpeechActConfirmation
|
||||
case strings.Contains(lower, "нет") || strings.Contains(lower, "отмен"):
|
||||
return interaction.SpeechActRejection
|
||||
case strings.Contains(lower, "привет") || strings.Contains(lower, "здравств"):
|
||||
return interaction.SpeechActConversational
|
||||
default:
|
||||
return interaction.SpeechActAssertion
|
||||
}
|
||||
}
|
||||
|
||||
func NeedsCognition(act interaction.SpeechAct) bool {
|
||||
return act == interaction.SpeechActQuestion || act == interaction.SpeechActRequestToInvestigate
|
||||
}
|
||||
|
||||
func FallbackText(act interaction.SpeechAct) string {
|
||||
switch act {
|
||||
case interaction.SpeechActDirective:
|
||||
return "Я понял действие как просьбу, но без отдельного Authority/Commit шага не выполняю мутацию."
|
||||
case interaction.SpeechActAssertion:
|
||||
return "Я записал это как утверждение человека. Это не считается проверенным фактом."
|
||||
case interaction.SpeechActCorrection:
|
||||
return "Исправление записано отдельной репликой; история не переписана."
|
||||
case interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
|
||||
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
|
||||
default:
|
||||
return "Сообщение записано."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestNewHumanUtteranceClassifiesQuestionAndRequiresCognition(t *testing.T) {
|
||||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||||
utterance, intent, relations, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "Кто ты?"}, time.Unix(10, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if utterance.SpeechAct != interaction.SpeechActQuestion || !NeedsCognition(utterance.SpeechAct) {
|
||||
t.Fatalf("expected cognitive question, got %s", utterance.SpeechAct)
|
||||
}
|
||||
if intent.Text != "Кто ты?" || len(relations) != 0 {
|
||||
t.Fatalf("unexpected intent or relations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHumanUtteranceAppendsCorrectionRelation(t *testing.T) {
|
||||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||||
utterance, _, relations, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "исправь прошлое", CorrectsUtteranceID: "utterance-0"}, time.Unix(10, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if utterance.SpeechAct != interaction.SpeechActCorrection {
|
||||
t.Fatalf("expected correction, got %s", utterance.SpeechAct)
|
||||
}
|
||||
if len(relations) != 1 || relations[0].Kind != interaction.RelationCorrects || relations[0].ToUtteranceID != "utterance-0" {
|
||||
t.Fatalf("unexpected correction relation: %#v", relations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConversationRequiresActorAndPrincipal(t *testing.T) {
|
||||
_, err := NewConversation(identity.ActorID(""), identity.PrincipalID("principal-1"), time.Unix(10, 0))
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid principal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package interaction owns durable human interaction semantics.
|
||||
package interaction
|
||||
@@ -0,0 +1,65 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyCredentialRef = errors.New("empty credential ref")
|
||||
ErrPathOutsideRoot = errors.New("secret path outside protected root")
|
||||
ErrEmptySecret = errors.New("empty secret")
|
||||
)
|
||||
|
||||
// FileResolver resolves CredentialRef metadata against a protected local file
|
||||
// backend. It is a bootstrap mechanism, not canonical configuration storage.
|
||||
type FileResolver struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewFileResolver(root string) (*FileResolver, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("secret backend root: %w", ErrPathOutsideRoot)
|
||||
}
|
||||
cleanRoot, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret backend root: %w", err)
|
||||
}
|
||||
return &FileResolver{root: cleanRoot}, nil
|
||||
}
|
||||
|
||||
func (r *FileResolver) ResolveFile(ref secretcontracts.SecretFileRef) (string, error) {
|
||||
if strings.TrimSpace(string(ref.CredentialRef)) == "" {
|
||||
return "", ErrEmptyCredentialRef
|
||||
}
|
||||
cleanPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(ref.Path)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !insideRoot(r.root, cleanPath) {
|
||||
return "", ErrPathOutsideRoot
|
||||
}
|
||||
content, err := os.ReadFile(cleanPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret := strings.TrimSpace(string(content))
|
||||
if secret == "" {
|
||||
return "", ErrEmptySecret
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func insideRoot(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
)
|
||||
|
||||
func TestFileResolverResolvesSecretInsideRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "interaction_db_password")
|
||||
if err := os.WriteFile(path, []byte("strong-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver, err := NewFileResolver(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := resolver.ResolveFile(secretcontracts.SecretFileRef{
|
||||
CredentialRef: "credential://domovoy/interaction/db-password/v1",
|
||||
Purpose: secretcontracts.SecretPurposeDatabasePassword,
|
||||
Consumer: secretcontracts.RuntimeConsumerCore,
|
||||
Path: path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "strong-secret" {
|
||||
t.Fatalf("unexpected secret value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResolverRejectsPathOutsideRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver, err := NewFileResolver(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = resolver.ResolveFile(secretcontracts.SecretFileRef{
|
||||
CredentialRef: "credential://domovoy/interaction/db-password/v1",
|
||||
Purpose: secretcontracts.SecretPurposeDatabasePassword,
|
||||
Consumer: secretcontracts.RuntimeConsumerCore,
|
||||
Path: outside,
|
||||
})
|
||||
if !errors.Is(err, ErrPathOutsideRoot) {
|
||||
t.Fatalf("expected ErrPathOutsideRoot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResolverRejectsEmptyCredentialRef(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "secret")
|
||||
if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver, err := NewFileResolver(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = resolver.ResolveFile(secretcontracts.SecretFileRef{
|
||||
Purpose: secretcontracts.SecretPurposeDatabasePassword,
|
||||
Consumer: secretcontracts.RuntimeConsumerCore,
|
||||
Path: path,
|
||||
})
|
||||
if !errors.Is(err, ErrEmptyCredentialRef) {
|
||||
t.Fatalf("expected ErrEmptyCredentialRef, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user