diff --git a/adapters/postgres/interaction_repository.go b/adapters/postgres/interaction_repository.go index cc1176a..1df363e 100644 --- a/adapters/postgres/interaction_repository.go +++ b/adapters/postgres/interaction_repository.go @@ -43,6 +43,31 @@ func (r *InteractionRepository) AppendUtterance(ctx context.Context, c interacti } return tx.Commit(ctx) } + +func (r *InteractionRepository) SaveSemanticResult(ctx context.Context, interpretation interaction.UtteranceInterpretation, projects []interaction.ProjectReference, relations []interaction.InteractionRelation) error { + 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) diff --git a/adapters/postgres/situation_repository.go b/adapters/postgres/situation_repository.go index 39e039f..aaf4f71 100644 --- a/adapters/postgres/situation_repository.go +++ b/adapters/postgres/situation_repository.go @@ -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 +} diff --git a/architecture/adrs/ADR-0011-human-intent-project-context.md b/architecture/adrs/ADR-0011-human-intent-project-context.md new file mode 100644 index 0000000..e137238 --- /dev/null +++ b/architecture/adrs/ADR-0011-human-intent-project-context.md @@ -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. + diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index 7dae2f5..0a6a095 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -40,7 +40,9 @@ "id": "mod.situation", "owns": [ "Situation", - "FocusBinding" + "FocusBinding", + "ProjectRegistry", + "ActiveProjectFocus" ], "database_schema": "situation", "boundary_class": "remote_capable", @@ -144,10 +146,10 @@ }, "interaction": { "id": "mod.interaction", - "owns": ["Conversation", "HumanUtterance", "UtteranceInterpretation", "OriginalHumanIntent", "InteractionRelation"], + "owns": ["Conversation", "HumanUtterance", "UtteranceInterpretation", "OriginalHumanIntent", "InteractionRelation", "ProjectReference"], "database_schema": "interaction", "boundary_class": "remote_capable", - "may_depend_on": ["contracts.identity", "contracts.situation", "contracts.cognitive", "platform.ids", "platform.events"] + "may_depend_on": ["contracts.identity", "contracts.situation", "contracts.epistemics", "contracts.cognitive", "platform.ids", "platform.events"] } }, "platform": { diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index 9035ef8..2ab25cf 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -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] @@ -70,10 +70,10 @@ modules: interaction: id: mod.interaction - owns: [Conversation, HumanUtterance, UtteranceInterpretation, OriginalHumanIntent, InteractionRelation] + owns: [Conversation, HumanUtterance, UtteranceInterpretation, OriginalHumanIntent, InteractionRelation, ProjectReference] database_schema: interaction boundary_class: remote_capable - may_depend_on: [contracts.identity, contracts.situation, contracts.cognitive, platform.ids, platform.events] + may_depend_on: [contracts.identity, contracts.situation, contracts.epistemics, contracts.cognitive, platform.ids, platform.events] platform: allowed_packages: diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index d597605..9bd8813 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -201,17 +201,55 @@ func main() { 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: utterance.SpeechAct, + SpeechAct: semantic.Interpretation.SpeechAct, InterpretationState: utterance.InterpretationState, - NeedsCognition: interactiondomain.NeedsCognition(utterance.SpeechAct), - FallbackText: interactiondomain.FallbackText(utterance.SpeechAct), + 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") diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index a9d7c4b..2be79d2 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -142,11 +142,14 @@ type browserChatRequest struct { } type browserChatResponse struct { - ConversationID string `json:"conversation_id"` - UtteranceID string `json:"utterance_id"` - SpeechAct string `json:"speech_act"` - ReplyText string `json:"reply_text"` - Cognition string `json:"cognition"` + 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) { @@ -313,7 +316,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { } reply := saved.FallbackText cognition := "not_requested" - if saved.NeedsCognition { + if saved.NeedsCognition && saved.Clarification != interaction.ClarificationRequired { cognition = "unavailable" requestID, idErr := ids.New() if idErr == nil { @@ -333,7 +336,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(browserChatResponse{ConversationID: string(saved.ConversationID), UtteranceID: string(saved.UtteranceID), SpeechAct: string(saved.SpeechAct), ReplyText: reply, Cognition: cognition}) + _ = 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 { diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index ef0dc60..095c303 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -32,7 +32,8 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; .chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; } .chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); } .chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); } -.chat-header span { margin-left: auto; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.chat-header 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; } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 12a88a1..9dfaa47 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -7,6 +7,7 @@ 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") || ""; @@ -38,6 +39,11 @@ function appendMessage(kind, text, meta = "") { 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}`; @@ -72,7 +78,10 @@ chatForm.addEventListener("submit", async (event) => { const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: text})}); conversationID = result.conversation_id; sessionStorage.setItem("domovoy.chat.conversation", conversationID); - appendMessage("assistant", result.reply_text || "Сообщение записано.", `${result.speech_act}; cognition=${result.cognition}`); + 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 { @@ -87,6 +96,7 @@ logoutButton.addEventListener("click", () => { sessionStorage.removeItem("domovoy.chat.token"); sessionStorage.removeItem("domovoy.chat.conversation"); messages.replaceChildren(); + updateActiveFocus(""); showLogin(); }); diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 6f0a38b..a502d29 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -26,6 +26,7 @@