Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ce2c568e | ||
|
|
445e9edd7c | ||
|
|
48dfd364e4 | ||
|
|
e4021523b8 | ||
|
|
790fcc0cea | ||
|
|
69ba990998 | ||
|
|
1918fb984c | ||
|
|
5bf97e2179 | ||
|
|
caad0ae2d4 | ||
|
|
e4b6121dc0 |
@@ -0,0 +1,91 @@
|
||||
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) SaveSemanticResult(ctx context.Context, interpretation interaction.UtteranceInterpretation, 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
for _, relation := range relations {
|
||||
if relation.ToUtteranceID == "" {
|
||||
continue
|
||||
}
|
||||
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) ON CONFLICT(relation_id) DO NOTHING`, 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()
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -39,3 +41,35 @@ func (r *SituationRepository) ByRuntime(ctx context.Context, runtime situation.R
|
||||
}
|
||||
return bindings, rows.Err()
|
||||
}
|
||||
|
||||
func (r *SituationRepository) ListProjects(ctx context.Context) ([]situation.ProjectDescriptor, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT p.project_id, p.display_name, p.recorded_at_unix_millis, COALESCE(array_agg(a.alias ORDER BY a.alias) FILTER (WHERE a.alias IS NOT NULL), '{}') FROM situation.projects p LEFT JOIN situation.project_aliases a ON a.project_id=p.project_id GROUP BY p.project_id, p.display_name, p.recorded_at_unix_millis ORDER BY p.display_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var projects []situation.ProjectDescriptor
|
||||
for rows.Next() {
|
||||
var project situation.ProjectDescriptor
|
||||
var aliases []string
|
||||
if err := rows.Scan(&project.ID, &project.DisplayName, &project.RecordedAtUnixMillis, &aliases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
project.Aliases = append(project.Aliases, situation.ProjectAlias(strings.ToLower(alias)))
|
||||
}
|
||||
projects = append(projects, project)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (r *SituationRepository) ActiveProjectFocus(ctx context.Context, actorID identity.ActorID, principalID identity.PrincipalID) (situation.ActiveProjectFocus, error) {
|
||||
var focus situation.ActiveProjectFocus
|
||||
err := r.pool.QueryRow(ctx, `SELECT actor_id,principal_id,project_id,project_display_name,provenance,confidence_millis,source_utterance_id,updated_at_unix_millis FROM situation.active_project_focus WHERE actor_id=$1 AND principal_id=$2`, actorID, principalID).Scan(&focus.ActorID, &focus.PrincipalID, &focus.ProjectID, &focus.ProjectDisplayName, &focus.Provenance, &focus.ConfidenceMillis, &focus.SourceUtteranceID, &focus.UpdatedAtUnixMillis)
|
||||
return focus, err
|
||||
}
|
||||
|
||||
func (r *SituationRepository) SaveActiveProjectFocus(ctx context.Context, focus situation.ActiveProjectFocus) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO situation.active_project_focus(actor_id,principal_id,project_id,project_display_name,provenance,confidence_millis,source_utterance_id,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(actor_id,principal_id) DO UPDATE SET project_id=EXCLUDED.project_id, project_display_name=EXCLUDED.project_display_name, provenance=EXCLUDED.provenance, confidence_millis=EXCLUDED.confidence_millis, source_utterance_id=EXCLUDED.source_utterance_id, updated_at_unix_millis=EXCLUDED.updated_at_unix_millis`, focus.ActorID, focus.PrincipalID, focus.ProjectID, focus.ProjectDisplayName, focus.Provenance, focus.ConfidenceMillis, focus.SourceUtteranceID, focus.UpdatedAtUnixMillis)
|
||||
return 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.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-0011 - Human Intent And Project Context Semantics
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-17
|
||||
Accepted under: DEV-000062 milestone decision
|
||||
|
||||
## Decision
|
||||
|
||||
DEV-000062 extends existing owners rather than creating a new canonical owner.
|
||||
|
||||
`interaction` owns typed, append-only interpretations of human utterances: speech-act confidence, original intent linkage, project reference candidates, correction/supersession relations, and whether a directive/decision/assertion is only a human semantic record. Interaction does not own truth, authority, execution, provider choice, or project canonical state.
|
||||
|
||||
`situation` owns the minimal V1 Project Registry and active project focus needed for human chat context resolution. It may store human-readable project aliases and per-actor/principal active focus references. Project resolution confidence and provenance are not Authority.
|
||||
|
||||
`epistemics` remains the owner of claims, observations, evidence and verified beliefs. A human assertion recorded through Interaction is not a verified fact. It may become input to a future Epistemics proposal only after a trusted observation/evidence path exists.
|
||||
|
||||
`work` remains the owner of durable Decisions/Tasks. DEV-000062 may record an explicit human decision/intention in Interaction, but it does not create a Work Decision or executable task unless a later accepted contract routes through Work/Authority/Commit.
|
||||
|
||||
## Consequences
|
||||
|
||||
The chat runtime can show a human-readable active project focus and persist semantic interpretation without direct mutation authority. Corrections are append-only. Multiple project references are represented as typed references, not copied project state. Cognitive help, when used, must remain behind the existing Cognitive Gateway/Router and produce validated candidates before persistence.
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
"id": "mod.situation",
|
||||
"owns": [
|
||||
"Situation",
|
||||
"FocusBinding"
|
||||
"FocusBinding",
|
||||
"ProjectRegistry",
|
||||
"ActiveProjectFocus"
|
||||
],
|
||||
"database_schema": "situation",
|
||||
"boundary_class": "remote_capable",
|
||||
@@ -141,6 +143,13 @@
|
||||
"may_depend_on": [
|
||||
"contracts.identity"
|
||||
]
|
||||
},
|
||||
"interaction": {
|
||||
"id": "mod.interaction",
|
||||
"owns": ["Conversation", "HumanUtterance", "UtteranceInterpretation", "OriginalHumanIntent", "InteractionRelation", "ProjectReference"],
|
||||
"database_schema": "interaction",
|
||||
"boundary_class": "remote_capable",
|
||||
"may_depend_on": ["contracts.identity", "contracts.situation", "contracts.epistemics", "contracts.cognitive", "platform.ids", "platform.events"]
|
||||
}
|
||||
},
|
||||
"platform": {
|
||||
@@ -150,7 +159,8 @@
|
||||
"platform.outbox",
|
||||
"platform.inbox",
|
||||
"platform.telemetry",
|
||||
"platform.errors"
|
||||
"platform.errors",
|
||||
"platform.secrets"
|
||||
],
|
||||
"rule": "platform owns mechanisms, never domain meaning"
|
||||
},
|
||||
@@ -162,7 +172,8 @@
|
||||
"work",
|
||||
"epistemics",
|
||||
"capability",
|
||||
"artifact"
|
||||
"artifact",
|
||||
"interaction"
|
||||
]
|
||||
},
|
||||
"continuity_runtime": {
|
||||
@@ -197,7 +208,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",
|
||||
|
||||
@@ -20,7 +20,7 @@ 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.authority, contracts.work, contracts.capability]
|
||||
@@ -68,6 +68,13 @@ modules:
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity]
|
||||
|
||||
interaction:
|
||||
id: mod.interaction
|
||||
owns: [Conversation, HumanUtterance, UtteranceInterpretation, OriginalHumanIntent, InteractionRelation, ProjectReference]
|
||||
database_schema: interaction
|
||||
boundary_class: remote_capable
|
||||
may_depend_on: [contracts.identity, contracts.situation, contracts.epistemics, 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,102 @@ 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
|
||||
}
|
||||
projects, err := repository.ListProjects(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var previousFocus *situation.ActiveProjectFocus
|
||||
storedFocus, err := repository.ActiveProjectFocus(r.Context(), session.ActorID, session.PrincipalID)
|
||||
if err == nil {
|
||||
previousFocus = &storedFocus
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now)
|
||||
if err != nil {
|
||||
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
activeFocusName := ""
|
||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||
if semantic.ActiveFocus != nil {
|
||||
if err := repository.SaveActiveProjectFocus(r.Context(), *semantic.ActiveFocus); err != nil {
|
||||
http.Error(w, "save project focus", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||
} else if previousFocus != nil {
|
||||
activeFocusName = previousFocus.ProjectDisplayName
|
||||
activeFocusProvenance = previousFocus.Provenance
|
||||
}
|
||||
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,
|
||||
}
|
||||
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 +613,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,24 @@ 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"`
|
||||
ActiveFocus string `json:"active_focus"`
|
||||
ProjectProvenance string `json:"project_provenance"`
|
||||
Clarification string `json:"clarification"`
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
@@ -249,6 +277,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 && 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}
|
||||
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, ActiveFocus: saved.ActiveProjectName, ProjectProvenance: string(saved.ProjectProvenance), Clarification: string(saved.Clarification)})
|
||||
}
|
||||
|
||||
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 +380,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,50 @@
|
||||
: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 { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
#active-focus { margin-left: auto; color: var(--good); }
|
||||
.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,107 @@
|
||||
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");
|
||||
const activeFocus = document.querySelector("#active-focus");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function updateActiveFocus(value, provenance = "") {
|
||||
if (!activeFocus) return;
|
||||
activeFocus.textContent = value ? `фокус: ${value}${provenance ? ` · ${provenance}` : ""}` : "фокус: не выбран";
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
token = "";
|
||||
conversationID = "";
|
||||
sessionStorage.removeItem("domovoy.chat.token");
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
updateActiveFocus("");
|
||||
showLogin();
|
||||
});
|
||||
|
||||
if (token) {
|
||||
showChat();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<!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="active-focus">фокус: не выбран</span>
|
||||
<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>
|
||||
@@ -5,3 +5,15 @@ import "git.cin.su/m/Assistant/platform/ids"
|
||||
type ObservationID ids.ID
|
||||
type ClaimID ids.ID
|
||||
type BeliefID ids.ID
|
||||
type VerificationState string
|
||||
type ClaimSourceKind string
|
||||
|
||||
const (
|
||||
VerificationUnverified VerificationState = "UNVERIFIED"
|
||||
VerificationVerified VerificationState = "VERIFIED"
|
||||
VerificationRejected VerificationState = "REJECTED"
|
||||
VerificationNotApplicable VerificationState = "NOT_APPLICABLE"
|
||||
|
||||
ClaimSourceAuthenticatedHuman ClaimSourceKind = "AUTHENTICATED_HUMAN"
|
||||
ClaimSourceTrustedObservation ClaimSourceKind = "TRUSTED_OBSERVATION"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
)
|
||||
|
||||
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
|
||||
type InterpretationID string
|
||||
type ProjectReferenceID string
|
||||
type ProjectReferenceRole string
|
||||
type ClarificationState 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"
|
||||
)
|
||||
|
||||
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 UtteranceInterpretation struct {
|
||||
ID InterpretationID
|
||||
UtteranceID HumanUtteranceID
|
||||
SpeechAct SpeechAct
|
||||
ConfidenceMillis int64
|
||||
Source string
|
||||
AssertionVerification string
|
||||
RequestedOutcome string
|
||||
Clarification ClarificationState
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ProjectReference struct {
|
||||
ID ProjectReferenceID
|
||||
UtteranceID HumanUtteranceID
|
||||
ProjectID situation.ProjectID
|
||||
ProjectDisplayName string
|
||||
Role ProjectReferenceRole
|
||||
Provenance situation.ProjectResolutionProvenance
|
||||
ConfidenceMillis int64
|
||||
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
|
||||
ActiveProjectName string
|
||||
ProjectProvenance situation.ProjectResolutionProvenance
|
||||
Clarification ClarificationState
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// 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
|
||||
}
|
||||
@@ -12,6 +12,19 @@ type FocusBindingID ids.ID
|
||||
type SessionID ids.ID
|
||||
type WorkspaceID ids.ID
|
||||
type ProjectID ids.ID
|
||||
type ProjectAlias string
|
||||
type ProjectResolutionProvenance string
|
||||
|
||||
const (
|
||||
ProjectResolutionExplicit ProjectResolutionProvenance = "EXPLICIT"
|
||||
ProjectResolutionInheritedFocus ProjectResolutionProvenance = "INHERITED_FOCUS"
|
||||
ProjectResolutionEntityReference ProjectResolutionProvenance = "ENTITY_REFERENCE"
|
||||
ProjectResolutionTaskReference ProjectResolutionProvenance = "TASK_REFERENCE"
|
||||
ProjectResolutionRepositoryReference ProjectResolutionProvenance = "REPOSITORY_REFERENCE"
|
||||
ProjectResolutionInferred ProjectResolutionProvenance = "INFERRED"
|
||||
ProjectResolutionUserConfirmed ProjectResolutionProvenance = "USER_CONFIRMED"
|
||||
ProjectResolutionAmbiguous ProjectResolutionProvenance = "AMBIGUOUS"
|
||||
)
|
||||
|
||||
// FocusBinding is the situation-owned runtime context used to resolve a request.
|
||||
// User text is deliberately absent: textual similarity is not a routing signal.
|
||||
@@ -30,3 +43,21 @@ type RuntimeBinding struct {
|
||||
SessionID SessionID
|
||||
Workspace WorkspaceID
|
||||
}
|
||||
|
||||
type ProjectDescriptor struct {
|
||||
ID ProjectID
|
||||
DisplayName string
|
||||
Aliases []ProjectAlias
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type ActiveProjectFocus struct {
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ProjectID ProjectID
|
||||
ProjectDisplayName string
|
||||
Provenance ProjectResolutionProvenance
|
||||
ConfidenceMillis int64
|
||||
SourceUtteranceID string
|
||||
UpdatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -146,6 +149,45 @@ CREATE TABLE IF NOT EXISTS situation.focus_bindings (
|
||||
active BOOLEAN NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS situation.projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (project_id <> ''),
|
||||
CHECK (display_name <> '')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS situation.project_aliases (
|
||||
project_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
PRIMARY KEY (project_id, alias),
|
||||
CHECK (project_id <> ''),
|
||||
CHECK (alias <> '')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS situation.active_project_focus (
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
project_display_name TEXT NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
source_utterance_id TEXT NOT NULL,
|
||||
updated_at_unix_millis BIGINT NOT NULL,
|
||||
PRIMARY KEY (actor_id, principal_id),
|
||||
CHECK (provenance IN ('EXPLICIT','INHERITED_FOCUS','ENTITY_REFERENCE','TASK_REFERENCE','REPOSITORY_REFERENCE','INFERRED','USER_CONFIRMED','AMBIGUOUS')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000)
|
||||
);
|
||||
INSERT INTO situation.projects(project_id, display_name, recorded_at_unix_millis)
|
||||
VALUES ('assistant', 'Assistant', 1), ('upo', 'УПО', 1)
|
||||
ON CONFLICT (project_id) DO NOTHING;
|
||||
INSERT INTO situation.project_aliases(project_id, alias, recorded_at_unix_millis)
|
||||
VALUES
|
||||
('assistant', 'assistant', 1),
|
||||
('assistant', 'домовой', 1),
|
||||
('assistant', 'ассистент', 1),
|
||||
('upo', 'упо', 1),
|
||||
('upo', 'upo', 1)
|
||||
ON CONFLICT (project_id, alias) DO NOTHING;
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE capability_role;
|
||||
@@ -304,4 +346,80 @@ 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','SUPERSEDES','CLARIFIES'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction.utterance_interpretations (
|
||||
interpretation_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
speech_act TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
assertion_verification TEXT NOT NULL,
|
||||
requested_outcome TEXT NOT NULL DEFAULT '',
|
||||
clarification TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (speech_act IN ('DIRECTIVE','ASSERTION','DECISION','PREFERENCE','QUESTION','HYPOTHESIS','CORRECTION','REQUEST_TO_INVESTIGATE','CONFIRMATION','REJECTION','CANCELLATION','CONVERSATIONAL','UNKNOWN')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000),
|
||||
CHECK (assertion_verification IN ('UNVERIFIED','VERIFIED','REJECTED','NOT_APPLICABLE')),
|
||||
CHECK (clarification IN ('NOT_REQUIRED','REQUIRED'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS interaction.project_references (
|
||||
project_reference_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
project_display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (role IN ('PRIMARY','RELATED','REFERENCE_SOURCE')),
|
||||
CHECK (provenance IN ('EXPLICIT','INHERITED_FOCUS','ENTITY_REFERENCE','TASK_REFERENCE','REPOSITORY_REFERENCE','INFERRED','USER_CONFIRMED','AMBIGUOUS')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000)
|
||||
);
|
||||
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;
|
||||
@@ -0,0 +1,89 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE situation_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS situation.projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (project_id <> ''),
|
||||
CHECK (display_name <> '')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS situation.project_aliases (
|
||||
project_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
PRIMARY KEY (project_id, alias),
|
||||
CHECK (project_id <> ''),
|
||||
CHECK (alias <> '')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS situation.active_project_focus (
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
project_display_name TEXT NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
source_utterance_id TEXT NOT NULL,
|
||||
updated_at_unix_millis BIGINT NOT NULL,
|
||||
PRIMARY KEY (actor_id, principal_id),
|
||||
CHECK (provenance IN ('EXPLICIT','INHERITED_FOCUS','ENTITY_REFERENCE','TASK_REFERENCE','REPOSITORY_REFERENCE','INFERRED','USER_CONFIRMED','AMBIGUOUS')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000)
|
||||
);
|
||||
|
||||
INSERT INTO situation.projects(project_id, display_name, recorded_at_unix_millis)
|
||||
VALUES ('assistant', 'Assistant', 1), ('upo', 'УПО', 1)
|
||||
ON CONFLICT (project_id) DO NOTHING;
|
||||
|
||||
INSERT INTO situation.project_aliases(project_id, alias, recorded_at_unix_millis)
|
||||
VALUES
|
||||
('assistant', 'assistant', 1),
|
||||
('assistant', 'домовой', 1),
|
||||
('assistant', 'ассистент', 1),
|
||||
('upo', 'упо', 1),
|
||||
('upo', 'upo', 1)
|
||||
ON CONFLICT (project_id, alias) DO NOTHING;
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE interaction_role;
|
||||
|
||||
ALTER TABLE interaction.relations DROP CONSTRAINT IF EXISTS relations_kind_check;
|
||||
ALTER TABLE interaction.relations
|
||||
ADD CONSTRAINT relations_kind_check
|
||||
CHECK (kind IN ('CORRECTS','RESPONDS_TO','REQUESTS_COGNITION','COGNITION_FALLBACK','SUPERSEDES','CLARIFIES'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.utterance_interpretations (
|
||||
interpretation_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
speech_act TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
assertion_verification TEXT NOT NULL,
|
||||
requested_outcome TEXT NOT NULL DEFAULT '',
|
||||
clarification TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (speech_act IN ('DIRECTIVE','ASSERTION','DECISION','PREFERENCE','QUESTION','HYPOTHESIS','CORRECTION','REQUEST_TO_INVESTIGATE','CONFIRMATION','REJECTION','CANCELLATION','CONVERSATIONAL','UNKNOWN')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000),
|
||||
CHECK (assertion_verification IN ('UNVERIFIED','VERIFIED','REJECTED','NOT_APPLICABLE')),
|
||||
CHECK (clarification IN ('NOT_REQUIRED','REQUIRED'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interaction.project_references (
|
||||
project_reference_id TEXT PRIMARY KEY,
|
||||
utterance_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
project_display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (role IN ('PRIMARY','RELATED','REFERENCE_SOURCE')),
|
||||
CHECK (provenance IN ('EXPLICIT','INHERITED_FOCUS','ENTITY_REFERENCE','TASK_REFERENCE','REPOSITORY_REFERENCE','INFERRED','USER_CONFIRMED','AMBIGUOUS')),
|
||||
CHECK (confidence_millis BETWEEN 0 AND 1000)
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
param(
|
||||
[string]$DockerContext = 'docker-prod',
|
||||
[string]$SshHost = 'docker',
|
||||
[string]$Endpoint = 'https://assistent.cin.su/healthz',
|
||||
[string]$Endpoint = 'https://assistant.cin.su/healthz',
|
||||
[string]$RemoteBackupRoot = '/home/m/assistant-backups/postgres',
|
||||
[string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres',
|
||||
[string]$NasStatusPath = '\\nas\MST\codex\_monitoring\domovoy\status.json',
|
||||
@@ -102,15 +102,15 @@ try {
|
||||
catch { Add-Check 'tls-certificate' $false $_.Exception.Message }
|
||||
|
||||
try {
|
||||
$assistantAddresses = @(Resolve-DnsName assistent.cin.su -Server 8.8.8.8 -Type A -DnsOnly | Where-Object Type -eq A | Select-Object -ExpandProperty IPAddress | Sort-Object -Unique)
|
||||
$assistantAddresses = @(Resolve-DnsName assistant.cin.su -Server 8.8.8.8 -Type A -DnsOnly | Where-Object Type -eq A | Select-Object -ExpandProperty IPAddress | Sort-Object -Unique)
|
||||
$dockerAddresses = @(Resolve-DnsName docker.cin.su -Server 8.8.8.8 -Type A -DnsOnly | Where-Object Type -eq A | Select-Object -ExpandProperty IPAddress | Sort-Object -Unique)
|
||||
$same = $assistantAddresses.Count -gt 0 -and $dockerAddresses.Count -gt 0 -and @(Compare-Object $assistantAddresses $dockerAddresses).Count -eq 0
|
||||
Add-Check 'public-dns' $same "assistent=$($assistantAddresses -join ',') docker=$($dockerAddresses -join ',')"
|
||||
Add-Check 'public-dns' $same "assistant=$($assistantAddresses -join ',') docker=$($dockerAddresses -join ',')"
|
||||
}
|
||||
catch { Add-Check 'public-dns' $false $_.Exception.Message }
|
||||
|
||||
try {
|
||||
$latestProduction = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort | tail -n1") -join '').Trim()
|
||||
$latestProduction = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort | tail -n1") -join '').Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or $latestProduction -notmatch '^\d{8}T\d{6}Z$') { throw 'invalid latest production backup' }
|
||||
$productionTime = [DateTime]::ParseExact($latestProduction, "yyyyMMdd'T'HHmmss'Z'", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal)
|
||||
$age = ([DateTime]::UtcNow - $productionTime).TotalHours
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000060-llm-free-provider-env-audit.json",
|
||||
"task_id": "DEV-000060",
|
||||
"task_file": "dev/tasks/DEV-000062-human-intent-evidence-project-context.json",
|
||||
"task_id": "DEV-000062",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"task_id": "DEV-000062",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0011",
|
||||
"base_repo_revision": "69ba99099845ef24affc15bdd8f152717183bb10",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add human intent, assertion/evidence boundary, project context and active focus semantics to Web Chat without new authority, provider binding, or execution capability.",
|
||||
"requirement_ids": [
|
||||
"ADR-0009",
|
||||
"ADR-0010",
|
||||
"ADR-0011",
|
||||
"INV-EPI-001",
|
||||
"INV-AUTH-001"
|
||||
],
|
||||
"allowed_change_scope": {
|
||||
"modules": [
|
||||
"interaction",
|
||||
"situation",
|
||||
"epistemics",
|
||||
"work"
|
||||
],
|
||||
"paths": [
|
||||
"architecture/adrs/ADR-0011-human-intent-project-context.md",
|
||||
"architecture/architecture-registry.yaml",
|
||||
"architecture/architecture-registry.json",
|
||||
"contracts/interaction/**",
|
||||
"contracts/situation/**",
|
||||
"contracts/epistemics/**",
|
||||
"modules/interaction/**",
|
||||
"modules/situation/**",
|
||||
"adapters/postgres/**",
|
||||
"database/bootstrap/**",
|
||||
"database/migrations/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/creator-web/**",
|
||||
"deploy/**",
|
||||
"tests/human_interaction/**",
|
||||
"tests/architecture/**",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000062-human-intent-evidence-project-context.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||
"modules/authority/**",
|
||||
"modules/commit/**",
|
||||
"cmd/commit-runtime/**",
|
||||
"cmd/cognitive-worker/**",
|
||||
"security/**",
|
||||
"ci/**",
|
||||
".gitea/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 3,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_third_party_dependencies": 0,
|
||||
"new_provider_adapters": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": [
|
||||
"CreatorSession",
|
||||
"Interaction Conversation/HumanUtterance",
|
||||
"Situation ProjectRegistry/ActiveFocus",
|
||||
"Epistemics assertion boundary",
|
||||
"Cognitive Gateway advisory"
|
||||
],
|
||||
"may_change": [
|
||||
"contracts/interaction@v1",
|
||||
"contracts/situation@v1",
|
||||
"contracts/epistemics@v1"
|
||||
]
|
||||
},
|
||||
"semantic_owner": "interaction+situation",
|
||||
"canonical_state": [
|
||||
"UtteranceInterpretation",
|
||||
"ProjectReference",
|
||||
"ConversationProjectFocus",
|
||||
"SituationProjectRegistry",
|
||||
"SituationActiveFocus"
|
||||
],
|
||||
"state_transitions": [
|
||||
"utterance RECORDED->INTERPRETED",
|
||||
"active focus SWITCHED by explicit human intent",
|
||||
"correction appended without rewriting prior utterance"
|
||||
],
|
||||
"side_effects_and_privileges": [
|
||||
"authenticated human semantic persistence",
|
||||
"project focus reference update only",
|
||||
"no execution",
|
||||
"no verified fact creation",
|
||||
"no ACTIVE binding promotion"
|
||||
],
|
||||
"persistence_and_migrations": [
|
||||
"interaction owner-local interpretation/project reference tables",
|
||||
"situation owner-local project registry and active focus tables",
|
||||
"no cross-owner FK",
|
||||
"no cross-owner SQL join dependency"
|
||||
],
|
||||
"failure_retry_restart": [
|
||||
"cognition unavailable keeps persistence working",
|
||||
"invalid candidate interpretation fails closed",
|
||||
"ambiguous destructive request requires clarification",
|
||||
"original human utterance and intent remain append-only"
|
||||
],
|
||||
"assumptions": [
|
||||
"DEV-000061 is production baseline",
|
||||
"existing CPU CANARY remains allowed only through current policy",
|
||||
"ACTIVE bindings remain none",
|
||||
"Project Registry V1 may be minimal and alias-based under Situation"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"explicit project focus switch",
|
||||
"assertion persists as unverified",
|
||||
"directive persists without execution claim",
|
||||
"correction/supersession append-only",
|
||||
"inherited focus for safe non-destructive reference",
|
||||
"multiple project references in one utterance",
|
||||
"ambiguous destructive request asks clarification",
|
||||
"cognition unavailable does not corrupt semantic state"
|
||||
],
|
||||
"required_tests": [
|
||||
"go test ./...",
|
||||
"go vet ./...",
|
||||
"architecture",
|
||||
"owner ACL",
|
||||
"LLM-free",
|
||||
"human interaction",
|
||||
"production live acceptance"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
+15
-1
@@ -1,6 +1,20 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000060 revision 1.
|
||||
Audit date: 2026-08-17. Active implementation: DEV-000062 revision 1.
|
||||
|
||||
## DEV-000062 checkpoint
|
||||
|
||||
- `DEV_000062_STATUS = COMPLETED`
|
||||
- `LAST_COMPLETED = DEV-000062 packet rev.1, ADR-0011, Interaction/Situation/Epistemics contract extensions, Situation Project Registry V1, active project focus semantics, Interaction utterance interpretation/project references, append-only correction/supersession foundation, Core Runtime semantic persistence wiring, Creator Web /chat focus display, production deployment and live /chat acceptance`
|
||||
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1; production live /chat acceptance`
|
||||
- `ACTIVE_BINDINGS = none`
|
||||
- `CANARY_BINDING = unchanged; not promoted to ACTIVE`
|
||||
- `OPENAI_REAL_CREDENTIAL = not required / not activated`
|
||||
- `PRODUCTION_REVISION = 445e9edd7c5b614b58dd925f89aa9fe49cf0a1d6`
|
||||
- `HUMAN_CHAT_URL = https://assistant.cin.su/chat`
|
||||
- `LIVE_ACCEPTANCE = PASS: explicit УПО focus, unverified assertion, directive/no execution, correction, explicit Assistant focus, current-focus question answered without cognition`
|
||||
- `NEXT_STEP = DEV-000063 Project Copilot planning, if separately approved`
|
||||
- `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, "не так") || strings.HasPrefix(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, "настрой") || strings.Contains(lower, "добавь") || strings.Contains(lower, "измени") || 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 lower == "да" || strings.HasPrefix(lower, "да,") || strings.Contains(lower, "подтверж"):
|
||||
return interaction.SpeechActConfirmation
|
||||
case lower == "нет" || strings.HasPrefix(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,242 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type SemanticResult struct {
|
||||
Interpretation interaction.UtteranceInterpretation
|
||||
Projects []interaction.ProjectReference
|
||||
Relations []interaction.InteractionRelation
|
||||
ActiveFocus *situation.ActiveProjectFocus
|
||||
Reply string
|
||||
NeedsCognition bool
|
||||
}
|
||||
|
||||
func InterpretUtterance(utterance interaction.HumanUtterance, projects []situation.ProjectDescriptor, previousFocus *situation.ActiveProjectFocus, now time.Time) (SemanticResult, error) {
|
||||
act := ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
|
||||
interpretationID, err := ids.New()
|
||||
if err != nil {
|
||||
return SemanticResult{}, err
|
||||
}
|
||||
millis := now.UnixMilli()
|
||||
result := SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
ID: interaction.InterpretationID(interpretationID),
|
||||
UtteranceID: utterance.ID,
|
||||
SpeechAct: act,
|
||||
ConfidenceMillis: confidenceFor(act),
|
||||
Source: "deterministic-v1",
|
||||
AssertionVerification: string(epistemics.VerificationUnverified),
|
||||
RequestedOutcome: requestedOutcome(utterance.Content, act),
|
||||
Clarification: interaction.ClarificationNotRequired,
|
||||
RecordedAtUnixMillis: millis,
|
||||
},
|
||||
NeedsCognition: NeedsCognition(act),
|
||||
}
|
||||
explicitFocus := explicitProjectFocus(utterance.Content, projects)
|
||||
projectRefs, err := projectReferences(utterance, projects, explicitFocus, previousFocus, millis)
|
||||
if err != nil {
|
||||
return SemanticResult{}, err
|
||||
}
|
||||
result.Projects = projectRefs
|
||||
if explicitFocus != nil {
|
||||
result.ActiveFocus = &situation.ActiveProjectFocus{
|
||||
ActorID: utterance.ActorID,
|
||||
PrincipalID: utterance.PrincipalID,
|
||||
ProjectID: explicitFocus.ProjectID,
|
||||
ProjectDisplayName: explicitFocus.ProjectDisplayName,
|
||||
Provenance: situation.ProjectResolutionExplicit,
|
||||
ConfidenceMillis: explicitFocus.ConfidenceMillis,
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
UpdatedAtUnixMillis: millis,
|
||||
}
|
||||
}
|
||||
if isAmbiguousDestructive(act, utterance.Content, result.Projects) {
|
||||
result.Interpretation.Clarification = interaction.ClarificationRequired
|
||||
result.Reply = "Уточните проект и цель: это потенциально разрушительное изменение, а текущая ссылка недостаточно надёжна."
|
||||
return result, nil
|
||||
}
|
||||
if act == interaction.SpeechActCorrection {
|
||||
relations, err := correctionRelations(utterance, millis)
|
||||
if err != nil {
|
||||
return SemanticResult{}, err
|
||||
}
|
||||
result.Relations = append(result.Relations, relations...)
|
||||
}
|
||||
result.Reply = semanticReply(utterance.Content, result, previousFocus)
|
||||
if isCurrentFocusQuestion(utterance.Content, act) && previousFocus != nil {
|
||||
result.NeedsCognition = false
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func explicitProjectFocus(message string, projects []situation.ProjectDescriptor) *interaction.ProjectReference {
|
||||
lower := strings.ToLower(message)
|
||||
if !(strings.Contains(lower, "работаем с") || strings.Contains(lower, "вернемся к") || strings.Contains(lower, "вернёмся к") || strings.HasPrefix(strings.TrimSpace(lower), "теперь ")) {
|
||||
return nil
|
||||
}
|
||||
for _, project := range projects {
|
||||
for _, alias := range projectAliases(project) {
|
||||
if strings.Contains(lower, alias) {
|
||||
return &interaction.ProjectReference{ProjectID: project.ID, ProjectDisplayName: project.DisplayName, Provenance: situation.ProjectResolutionExplicit, ConfidenceMillis: 980}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectReferences(utterance interaction.HumanUtterance, projects []situation.ProjectDescriptor, explicit *interaction.ProjectReference, previousFocus *situation.ActiveProjectFocus, millis int64) ([]interaction.ProjectReference, error) {
|
||||
if explicit != nil {
|
||||
ref, err := newProjectReference(utterance.ID, explicit.ProjectID, explicit.ProjectDisplayName, interaction.ProjectRolePrimary, explicit.Provenance, explicit.ConfidenceMillis, millis)
|
||||
return []interaction.ProjectReference{ref}, err
|
||||
}
|
||||
lower := strings.ToLower(utterance.Content)
|
||||
var refs []interaction.ProjectReference
|
||||
for _, project := range projects {
|
||||
for _, alias := range projectAliases(project) {
|
||||
if strings.Contains(lower, alias) {
|
||||
role := projectRoleForAlias(lower, alias)
|
||||
ref, err := newProjectReference(utterance.ID, project.ID, project.DisplayName, role, situation.ProjectResolutionEntityReference, 850, millis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 && previousFocus != nil && strings.Contains(lower, "туда") {
|
||||
ref, err := newProjectReference(utterance.ID, previousFocus.ProjectID, previousFocus.ProjectDisplayName, interaction.ProjectRolePrimary, situation.ProjectResolutionInheritedFocus, 700, millis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
if len(refs) > 1 {
|
||||
hasPrimary := false
|
||||
for _, ref := range refs {
|
||||
hasPrimary = hasPrimary || ref.Role == interaction.ProjectRolePrimary
|
||||
}
|
||||
if !hasPrimary {
|
||||
refs[len(refs)-1].Role = interaction.ProjectRolePrimary
|
||||
}
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func projectRoleForAlias(lower string, alias string) interaction.ProjectReferenceRole {
|
||||
aliasIndex := strings.Index(lower, alias)
|
||||
if aliasIndex < 0 {
|
||||
return interaction.ProjectRoleRelated
|
||||
}
|
||||
before := strings.TrimSpace(lower[:aliasIndex])
|
||||
afterStart := aliasIndex + len(alias)
|
||||
after := ""
|
||||
if afterStart < len(lower) {
|
||||
after = strings.TrimSpace(lower[afterStart:])
|
||||
}
|
||||
if strings.Contains(before, "как") || strings.Contains(before, "посмотри") || strings.Contains(after, "как источник") {
|
||||
return interaction.ProjectRoleReferenceSource
|
||||
}
|
||||
targetMarkers := []string{"сделай", "добавь", "измени", "поменяй", "перенеси", "используй", "настрой", "в "}
|
||||
for _, marker := range targetMarkers {
|
||||
if strings.Contains(before, marker) {
|
||||
return interaction.ProjectRolePrimary
|
||||
}
|
||||
}
|
||||
return interaction.ProjectRoleRelated
|
||||
}
|
||||
|
||||
func newProjectReference(utteranceID interaction.HumanUtteranceID, projectID situation.ProjectID, display string, role interaction.ProjectReferenceRole, provenance situation.ProjectResolutionProvenance, confidence int64, millis int64) (interaction.ProjectReference, error) {
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return interaction.ProjectReference{}, err
|
||||
}
|
||||
return interaction.ProjectReference{ID: interaction.ProjectReferenceID(id), UtteranceID: utteranceID, ProjectID: projectID, ProjectDisplayName: display, Role: role, Provenance: provenance, ConfidenceMillis: confidence, RecordedAtUnixMillis: millis}, nil
|
||||
}
|
||||
|
||||
func projectAliases(project situation.ProjectDescriptor) []string {
|
||||
values := []string{strings.ToLower(project.DisplayName), strings.ToLower(string(project.ID))}
|
||||
for _, alias := range project.Aliases {
|
||||
values = append(values, strings.ToLower(string(alias)))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func correctionRelations(utterance interaction.HumanUtterance, millis int64) ([]interaction.InteractionRelation, error) {
|
||||
if utterance.CorrectsUtteranceID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := ids.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []interaction.InteractionRelation{{ID: interaction.InteractionRelationID(id), FromUtteranceID: utterance.ID, ToUtteranceID: utterance.CorrectsUtteranceID, Kind: interaction.RelationSupersedes, RecordedAtUnixMillis: millis}}, nil
|
||||
}
|
||||
|
||||
func confidenceFor(act interaction.SpeechAct) int64 {
|
||||
if act == interaction.SpeechActUnknown {
|
||||
return 300
|
||||
}
|
||||
return 800
|
||||
}
|
||||
|
||||
func requestedOutcome(message string, act interaction.SpeechAct) string {
|
||||
if act == interaction.SpeechActDirective || act == interaction.SpeechActDecision || act == interaction.SpeechActRequestToInvestigate {
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inheritedFocusAllowed(act interaction.SpeechAct, message string) bool {
|
||||
return strings.Contains(strings.ToLower(message), "туда") && act != interaction.SpeechActUnknown
|
||||
}
|
||||
|
||||
func isAmbiguousDestructive(act interaction.SpeechAct, message string, projects []interaction.ProjectReference) bool {
|
||||
lower := strings.ToLower(message)
|
||||
destructive := strings.Contains(lower, "удали") || strings.Contains(lower, "снеси") || strings.Contains(lower, "удалить")
|
||||
if !destructive || act != interaction.SpeechActDirective {
|
||||
return false
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return true
|
||||
}
|
||||
return projects[0].Provenance == situation.ProjectResolutionInheritedFocus || projects[0].Provenance == situation.ProjectResolutionInferred
|
||||
}
|
||||
|
||||
func semanticReply(message string, result SemanticResult, previousFocus *situation.ActiveProjectFocus) string {
|
||||
if result.ActiveFocus != nil {
|
||||
return "Фокус переключён: " + result.ActiveFocus.ProjectDisplayName + "."
|
||||
}
|
||||
switch result.Interpretation.SpeechAct {
|
||||
case interaction.SpeechActAssertion:
|
||||
return "Записал как утверждение человека. Это пока не проверенный факт."
|
||||
case interaction.SpeechActDirective:
|
||||
return "Записал просьбу как намерение пользователя. Ничего не выполнено без отдельного подтверждённого действия."
|
||||
case interaction.SpeechActDecision:
|
||||
return "Записал это как явное решение пользователя для дальнейшей обработки."
|
||||
case interaction.SpeechActCorrection:
|
||||
return "Исправление записано новым сообщением; прежняя история сохранена."
|
||||
case interaction.SpeechActQuestion:
|
||||
lower := strings.ToLower(message)
|
||||
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
|
||||
return "Сейчас в фокусе: " + previousFocus.ProjectDisplayName + "."
|
||||
}
|
||||
}
|
||||
return FallbackText(result.Interpretation.SpeechAct)
|
||||
}
|
||||
|
||||
func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
|
||||
if act != interaction.SpeechActQuestion {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"git.cin.su/m/Assistant/contracts/situation"
|
||||
)
|
||||
|
||||
func TestInterpretUtteranceRecordsExplicitProjectFocus(t *testing.T) {
|
||||
result := interpretForTest(t, "Сейчас работаем с УПО.", nil, "")
|
||||
if result.ActiveFocus == nil || result.ActiveFocus.ProjectID != "upo" {
|
||||
t.Fatalf("expected explicit UPO focus, got %#v", result.ActiveFocus)
|
||||
}
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActAssertion {
|
||||
t.Fatalf("expected assertion, got %s", result.Interpretation.SpeechAct)
|
||||
}
|
||||
if result.Projects[0].Provenance != situation.ProjectResolutionExplicit {
|
||||
t.Fatalf("expected explicit provenance, got %#v", result.Projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceAssertionIsNotVerifiedFact(t *testing.T) {
|
||||
result := interpretForTest(t, "На сервере сейчас свободно 300 ГБ.", nil, "")
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActAssertion {
|
||||
t.Fatalf("expected assertion, got %s", result.Interpretation.SpeechAct)
|
||||
}
|
||||
if result.Interpretation.AssertionVerification != "UNVERIFIED" || !strings.Contains(result.Reply, "не проверенный факт") {
|
||||
t.Fatalf("expected unverified human assertion, got %#v reply=%q", result.Interpretation, result.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) {
|
||||
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
|
||||
t.Fatalf("expected directive, got %s", result.Interpretation.SpeechAct)
|
||||
}
|
||||
if result.Interpretation.RequestedOutcome == "" || !strings.Contains(result.Reply, "Ничего не выполнено") {
|
||||
t.Fatalf("expected non-executed requested outcome, got %#v reply=%q", result.Interpretation, result.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceCorrectionIsAppendOnlySupersedes(t *testing.T) {
|
||||
result := interpretForTest(t, "Нет, используй УПО2.", activeFocus("upo", "УПО"), "utterance-previous")
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActCorrection {
|
||||
t.Fatalf("expected correction, got %s", result.Interpretation.SpeechAct)
|
||||
}
|
||||
if len(result.Relations) != 1 || result.Relations[0].Kind != interaction.RelationSupersedes || result.Relations[0].ToUtteranceID != "utterance-previous" {
|
||||
t.Fatalf("expected supersedes relation, got %#v", result.Relations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) {
|
||||
result := interpretForTest(t, "Что мы сейчас делаем?", activeFocus("assistant", "Assistant"), "")
|
||||
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion {
|
||||
t.Fatalf("expected question, got %s", result.Interpretation.SpeechAct)
|
||||
}
|
||||
if !strings.Contains(result.Reply, "Assistant") {
|
||||
t.Fatalf("expected focus-aware reply, got %q", result.Reply)
|
||||
}
|
||||
if result.NeedsCognition {
|
||||
t.Fatalf("focus question should be answered from canonical focus without cognition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
|
||||
result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "")
|
||||
if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
|
||||
t.Fatalf("expected inherited Assistant focus, got %#v", result.Projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceMultiProjectReferenceRoles(t *testing.T) {
|
||||
result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "")
|
||||
if len(result.Projects) != 2 {
|
||||
t.Fatalf("expected two project references, got %#v", result.Projects)
|
||||
}
|
||||
roles := map[situation.ProjectID]interaction.ProjectReferenceRole{}
|
||||
for _, ref := range result.Projects {
|
||||
roles[ref.ProjectID] = ref.Role
|
||||
}
|
||||
if roles["assistant"] != interaction.ProjectRoleReferenceSource || roles["upo"] != interaction.ProjectRolePrimary {
|
||||
t.Fatalf("unexpected roles: %#v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretUtteranceAmbiguousDestructiveRequiresClarification(t *testing.T) {
|
||||
result := interpretForTest(t, "Удали туда старую базу.", activeFocus("assistant", "Assistant"), "")
|
||||
if result.Interpretation.Clarification != interaction.ClarificationRequired {
|
||||
t.Fatalf("expected clarification, got %#v", result.Interpretation)
|
||||
}
|
||||
if result.NeedsCognition {
|
||||
t.Fatalf("ambiguous destructive directive must not require cognition")
|
||||
}
|
||||
}
|
||||
|
||||
func interpretForTest(t *testing.T, message string, previous *situation.ActiveProjectFocus, corrects interaction.HumanUtteranceID) SemanticResult {
|
||||
t.Helper()
|
||||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||||
utterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message, CorrectsUtteranceID: corrects}, time.Unix(100, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := InterpretUtterance(utterance, projectRegistryForTest(), previous, time.Unix(100, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func projectRegistryForTest() []situation.ProjectDescriptor {
|
||||
return []situation.ProjectDescriptor{
|
||||
{ID: "assistant", DisplayName: "Assistant", Aliases: []situation.ProjectAlias{"assistant", "ассистент", "домовой"}},
|
||||
{ID: "upo", DisplayName: "УПО", Aliases: []situation.ProjectAlias{"упо", "upo"}},
|
||||
}
|
||||
}
|
||||
|
||||
func activeFocus(projectID situation.ProjectID, displayName string) *situation.ActiveProjectFocus {
|
||||
return &situation.ActiveProjectFocus{
|
||||
ActorID: identity.ActorID("actor-1"),
|
||||
PrincipalID: identity.PrincipalID("principal-1"),
|
||||
ProjectID: projectID,
|
||||
ProjectDisplayName: displayName,
|
||||
Provenance: situation.ProjectResolutionExplicit,
|
||||
ConfidenceMillis: 980,
|
||||
SourceUtteranceID: "utterance-focus",
|
||||
UpdatedAtUnixMillis: time.Unix(90, 0).UnixMilli(),
|
||||
}
|
||||
}
|
||||
@@ -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