DEV-000061 checkpoint interaction chat foundation
CI / test (push) Failing after 10s
CI / test (pull_request) Failing after 11s

This commit is contained in:
m
2026-08-17 14:10:04 +03:00
parent e4b6121dc0
commit 41154be211
26 changed files with 1376 additions and 8 deletions
+105
View File
@@ -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,
})
}