Compare commits

...
Author SHA1 Message Date
m cd4b4a95c0 feat: make discovered ids actionable
CI / test (push) Failing after 14s
CI / test (pull_request) Failing after 48s
2026-08-21 03:59:45 +03:00
m e55c0ba88e feat: simplify creator context selection
CI / test (push) Failing after 16s
CI / test (pull_request) Failing after 14s
2026-08-21 03:57:53 +03:00
m 301e5d0b27 feat: improve creator cabinet navigation
CI / test (push) Failing after 14s
CI / test (pull_request) Failing after 14s
2026-08-21 03:43:58 +03:00
m 4055319eb6 docs: record context setup refinement
CI / test (push) Failing after 14s
2026-08-21 03:22:39 +03:00
m 93b0965485 feat: simplify human context setup
CI / test (push) Failing after 15s
2026-08-21 03:21:48 +03:00
m 406a106e30 fix: bust Creator cabinet asset cache
CI / test (push) Failing after 15s
2026-08-21 03:19:54 +03:00
m 5789b70e9c docs: record cabinet diagnostic recovery
CI / test (push) Failing after 16s
2026-08-21 03:18:27 +03:00
m 8397e69c2f docs: record cabinet readability hotfix
CI / test (push) Failing after 15s
2026-08-21 03:16:18 +03:00
m 4226c986d7 fix: make Creator cabinet readable on wide screens 2026-08-21 03:15:07 +03:00
m 918748ca57 docs: record Creator cabinet operations rollout
CI / test (push) Failing after 15s
2026-08-21 02:49:57 +03:00
m 3ed64631f6 feat: make Creator cabinet operable for humans 2026-08-21 02:48:45 +03:00
m 407275c796 docs: record Assistant production recovery
CI / test (push) Failing after 15s
2026-08-21 02:23:40 +03:00
m 3aa0a1d452 docs: record production promotion access gap
CI / test (push) Failing after 15s
2026-08-21 01:43:06 +03:00
m 49036e6397 fix: fail closed on missing production inputs 2026-08-21 01:42:24 +03:00
m da94ed17a6 docs: record production artifact and schema evidence 2026-08-21 01:30:13 +03:00
m 1ceb5f6761 docs: record production runtime blockers 2026-08-21 01:29:07 +03:00
m 4f53c110ea docs: record current local repair checkpoint 2026-08-21 01:27:29 +03:00
m d800e49a2a fix: sanitize creator console errors 2026-08-21 01:26:42 +03:00
m 293cb4f4c1 fix: avoid exposing client error details 2026-08-21 01:23:11 +03:00
m 73935b3f94 fix: preserve cognitive deferral in planning 2026-08-21 01:21:00 +03:00
m ed67fd7ba9 fix: validate cognitive interpretation boundary 2026-08-21 01:19:10 +03:00
m 8448e95257 fix: preserve selected semantic candidate provenance 2026-08-21 01:15:48 +03:00
m 1389d6b24c fix: remove stray escaped newline token in planning helper
CI / test (push) Failing after 14s
2026-08-18 17:47:36 +03:00
m d50d709d69 refactor: prioritize cognitive interpretation over deterministic rules
CI / test (push) Failing after 14s
2026-08-18 17:46:14 +03:00
m 2e0c0236aa fix: avoid hardcoded chat runtime fallback message
CI / test (push) Failing after 14s
2026-08-18 17:30:48 +03:00
m 5db143df3b Prefer structured planning hints over raw message heuristics
CI / test (push) Failing after 12s
2026-08-18 17:15:56 +03:00
m 4321bd627d Add semantic selection mismatch safeguards for conversational vs consequential paths
CI / test (push) Failing after 15s
2026-08-18 17:09:04 +03:00
m b6237334c6 Add diagnostic trace coverage for intent selection lineage
CI / test (push) Failing after 14s
2026-08-18 16:54:46 +03:00
m b025bec777 Improve assistant status probes and cognition routing coverage
CI / test (push) Failing after 14s
2026-08-18 16:50:19 +03:00
m 1a62be1e34 Expand assistant-status phrase tolerance for cognitive routing
CI / test (push) Failing after 14s
2026-08-18 16:45:32 +03:00
m b1a51a8e44 Route assistant-status phrases to cognitive candidate
CI / test (push) Failing after 14s
2026-08-18 16:43:04 +03:00
m 52074e2895 Move cognition-first interpretation for free-form Russian
CI / test (push) Failing after 14s
2026-08-18 16:40:46 +03:00
21 changed files with 1474 additions and 242 deletions
+27
View File
@@ -112,6 +112,9 @@ func parseInterpretationCandidate(text string) (cognitive.GatewayInterpretationC
if candidate.SecondarySpeechAct != "" && !validSpeechAct(candidate.SecondarySpeechAct) {
return cognitive.GatewayInterpretationCandidate{}, false
}
if candidate.SecondarySpeechAct == "" {
candidate.SecondarySpeechAct = "UNKNOWN"
}
switch candidate.Applicability {
case cognitive.InterpretationApplicable, cognitive.InterpretationLowConfidence, cognitive.InterpretationMixedIntent, cognitive.InterpretationNeedsBroader, cognitive.InterpretationClarificationRequired:
default:
@@ -120,6 +123,11 @@ func parseInterpretationCandidate(text string) (cognitive.GatewayInterpretationC
if candidate.Clarification != "NOT_REQUIRED" && candidate.Clarification != "REQUIRED" {
return cognitive.GatewayInterpretationCandidate{}, false
}
for _, hint := range candidate.CapabilityHints {
if !validCapabilityKind(hint) {
return cognitive.GatewayInterpretationCandidate{}, false
}
}
return candidate, true
}
@@ -131,6 +139,25 @@ func validSpeechAct(value string) bool {
return false
}
func validCapabilityKind(value capability.CapabilityKind) bool {
switch value {
case capability.CapabilityKindReadEmail,
capability.CapabilityKindSendEmail,
capability.CapabilityKindReadDatabase,
capability.CapabilityKindQueryLogs,
capability.CapabilityKindInspectProject,
capability.CapabilityKindModifyCode,
capability.CapabilityKindRunTests,
capability.CapabilityKindWebResearch,
capability.CapabilityKindCognitiveReasoning,
capability.CapabilityKindScheduleReminder,
capability.CapabilityKindVerifyFact:
return true
default:
return false
}
}
func writeInterpretationResponse(w http.ResponseWriter, response cognitive.GatewayInterpretationResponse) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
@@ -17,6 +17,7 @@ import (
"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/platform/ids"
"git.cin.su/m/Assistant/contracts/situation"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
@@ -74,7 +75,32 @@ func TestLiveInterpretationTrace(t *testing.T) {
now: time.Now,
allowCanary: true,
}
for _, message := range []string{"ты кто", "можешь расказать об утре"} {
for _, message := range []string{
"расскажи как это работает",
"Расскажи, как это работает?",
"Расскажи как это работает",
"можешь объяснить почему база растёт",
"почему postgres использует столько памяти",
"ПОЧЕМУ postgres использует так много памяти",
"домовой не видит ии",
"домовой не вижит ии",
"разберись почему сервис упал",
"посмотри что произошло",
"исправь ошибку если найдёшь",
"разберись почему падает и исправь если причина очевидна",
"нет не UPO а UPO2",
"нет не UPO а UPO2 и обнови конфиг",
"postgres сейчас работает",
"основная база называется UPO",
"привет",
"спасибо",
"ты кто",
"ты кто?",
"как тебя зовут",
"кто ты вообще",
"что ты думаешь",
"расскажи подробнее",
} {
conversation := interaction.Conversation{ID: "trace-conversation", ActorID: "trace-actor", PrincipalID: "domovoy-creator-principal", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
utterance, _, _, err := interactiondomain.NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message}, time.Now())
if err != nil {
@@ -121,15 +147,27 @@ func TestLiveInterpretationTrace(t *testing.T) {
if rawResponse == "" {
t.Fatalf("message=%q raw response is empty", message)
}
if message == "ты кто" {
if semantic.Interpretation.SpeechAct != interaction.SpeechActQuestion || !semantic.NeedsCognition {
t.Fatalf("semantic path still fails punctuation-free identity question: %+v", semantic.Interpretation)
}
if gatewayResponse.Candidate.PrimarySpeechAct != "QUESTION" {
t.Fatalf("gateway did not preserve question semantics: %+v", gatewayResponse.Candidate)
}
cognitiveCandidate, err := cognitiveCandidateFromGateway(utterance.ID, gatewayResponse.Candidate, time.Now())
if err != nil {
t.Fatal(err)
}
t.Logf("TRACE message=%q deterministic=%s/%s needs_cognition=%t raw=%q gateway_candidate=%+v", message, semantic.Interpretation.SpeechAct, semantic.Interpretation.Applicability, semantic.NeedsCognition, truncate(rawResponse, 180), gatewayResponse.Candidate)
selected := interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, cognitiveCandidate, nil)
cognitionUsed := selected.Interpretation.Source == string(interaction.CandidateSourceCognitive)
clarificationReason := string(selected.Interpretation.Clarification)
t.Logf("TRACE message=%q", message)
deterministic := semantic.Candidates[0]
t.Logf(" deterministic candidate: %+v", deterministic)
t.Logf(" raw response=%q", truncate(rawResponse, 180))
t.Logf(" cognitive candidate: %+v", cognitiveCandidate)
t.Logf(" selected interpretation: act=%s secondary=%s selection_source=%s needs_cognition=%t",
selected.Interpretation.SpeechAct,
selected.Interpretation.SecondarySpeechAct,
selected.Interpretation.Source,
selected.Interpretation.NeedsCognition,
)
t.Logf(" cognition used: %t", cognitionUsed)
t.Logf(" clarification reason: %s", clarificationReason)
t.Logf(" reply=%q", selected.Reply)
}
}
@@ -217,3 +255,30 @@ func truncate(value string, max int) string {
}
return value[:max] + "..."
}
func cognitiveCandidateFromGateway(utteranceID interaction.HumanUtteranceID, candidate cognitive.GatewayInterpretationCandidate, now interface{ UnixMilli() int64 }) (interaction.SemanticCandidate, error) {
id, err := ids.New()
if err != nil {
return interaction.SemanticCandidate{}, err
}
capabilityHints := make([]string, 0, len(candidate.CapabilityHints))
for _, hint := range candidate.CapabilityHints {
capabilityHints = append(capabilityHints, string(hint))
}
return interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(id),
UtteranceID: utteranceID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateSelected,
PrimarySpeechAct: interaction.SpeechAct(candidate.PrimarySpeechAct),
SecondarySpeechAct: interaction.SpeechAct(candidate.SecondarySpeechAct),
ConfidenceMillis: candidate.ConfidenceMillis,
Applicability: interaction.SemanticApplicability(candidate.Applicability),
RequestedOutcome: candidate.RequestedOutcome,
Clarification: interaction.ClarificationState(candidate.Clarification),
NeedsCognition: candidate.NeedsCognition,
SelectionReason: candidate.SelectionReason,
CapabilityHints: capabilityHints,
RecordedAtUnixMillis: now.UnixMilli(),
}, nil
}
@@ -0,0 +1,33 @@
package main
import (
"testing"
"git.cin.su/m/Assistant/contracts/capability"
)
func TestParseInterpretationCandidateNormalizesOmittedSecondarySpeechAct(t *testing.T) {
candidate, ok := parseInterpretationCandidate(`{"primary_speech_act":"QUESTION","confidence_millis":800,"applicability":"APPLICABLE","clarification":"NOT_REQUIRED"}`)
if !ok {
t.Fatal("expected valid candidate")
}
if candidate.SecondarySpeechAct != "UNKNOWN" {
t.Fatalf("expected omitted secondary speech act to normalize to UNKNOWN, got %q", candidate.SecondarySpeechAct)
}
}
func TestParseInterpretationCandidateRejectsUnknownCapabilityHint(t *testing.T) {
_, ok := parseInterpretationCandidate(`{"primary_speech_act":"QUESTION","confidence_millis":800,"applicability":"APPLICABLE","clarification":"NOT_REQUIRED","capability_hints":["INVENTED_CAPABILITY"]}`)
if ok {
t.Fatal("expected unknown capability hint to be rejected")
}
}
func TestValidCapabilityKindMatchesContractEnum(t *testing.T) {
if !validCapabilityKind(capability.CapabilityKindCognitiveReasoning) {
t.Fatal("expected cognitive reasoning capability to be valid")
}
if validCapabilityKind(capability.CapabilityKind("INVENTED_CAPABILITY")) {
t.Fatal("invented capability must be invalid")
}
}
+2 -7
View File
@@ -61,12 +61,7 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
}
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
switch result.Interpretation.Applicability {
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
return true
default:
return false
}
return interactiondomain.ShouldAttemptCognitive(result)
}
func buildGatewayInterpretationRequest(session identity.CreatorSession, utterance interaction.HumanUtterance, semantic interactiondomain.SemanticResult, projectNames []string) cognitive.GatewayInterpretationRequest {
@@ -122,4 +117,4 @@ func capabilityHintStrings(values []capability.CapabilityKind) []string {
result = append(result, string(value))
}
return result
}
}
+2 -2
View File
@@ -293,7 +293,7 @@ func main() {
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
return
}
if shouldRequestBroaderInterpretation(semantic) {
if interactiondomain.ShouldAttemptCognitive(semantic) {
projectNames := make([]string, 0, len(semantic.Projects))
for _, ref := range semantic.Projects {
if ref.ProjectDisplayName != "" {
@@ -1170,4 +1170,4 @@ func evidenceRefsToEpistemics(values []work.EvidenceRef) []epistemics.EvidenceRe
result = append(result, epistemics.EvidenceRef(value))
}
return result
}
}
+6
View File
@@ -233,6 +233,12 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "context-preset") ||
!strings.Contains(response.Body.String(), "context-state") ||
!strings.Contains(response.Body.String(), "copy-context") ||
!strings.Contains(response.Body.String(), "К содержимому") ||
!strings.Contains(response.Body.String(), "aria-current=\"page\"") ||
!strings.Contains(response.Body.String(), "section-nav-link") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
+1 -1
View File
@@ -190,7 +190,7 @@ async function sendChatMessage(text, options = {}) {
activateTab("today-panel");
}
} catch {
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
appendMessage("assistant", "Не могу отправить сообщение. Попробуйте ещё раз.");
} finally {
sendButton.disabled = false;
messageInput.focus();
+110 -14
View File
@@ -1,12 +1,12 @@
:root {
--ink: #e9eee7;
--muted: #8c9a91;
--faint: #58645d;
--base: #0b100e;
--panel: #101714;
--panel-high: #151e1a;
--line: #2a352f;
--line-soft: #1d2822;
--ink: #eef2ec;
--muted: #a7b2aa;
--faint: #718078;
--base: #0f1512;
--panel: #17201b;
--panel-high: #202b25;
--line: #34423a;
--line-soft: #26332c;
--signal: #c9a85f;
--signal-bright: #e7c97b;
--good: #78c6a3;
@@ -21,13 +21,15 @@
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { background: var(--base); color: var(--ink); font-family: var(--sans); }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .15), transparent 38%), var(--base); }
html { background: var(--base); color: var(--ink); font-family: var(--sans); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .13), transparent 38%), var(--base); }
button, input, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
.skip-link { position: fixed; top: 10px; left: 10px; z-index: 30; padding: 10px 13px; color: #11150f; background: var(--signal-bright); font: 600 10px/1 var(--mono); text-decoration: none; transform: translateY(-160%); transition: transform .18s; }
.skip-link:focus { transform: translateY(0); }
.grain { position: fixed; inset: 0; pointer-events: none; opacity: .18; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); }
.grain { position: fixed; inset: 0; pointer-events: none; opacity: .045; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); }
.eyebrow, .rail-index { margin: 0 0 12px; color: var(--signal); font: 600 11px/1 var(--mono); letter-spacing: .18em; text-transform: uppercase; }
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; position: relative; overflow: hidden; }
@@ -63,6 +65,9 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.wordmark strong { font: 400 20px/1 var(--serif); }
.wordmark small { margin-top: 4px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.topbar-nav { display: flex; align-items: center; gap: 4px; margin-right: 8px; }
.topbar-nav a { padding: 9px 10px; color: var(--muted); text-decoration: none; font: 600 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.topbar-nav a:hover, .topbar-nav a:focus-visible { color: var(--ink); background: var(--panel-high); outline: 0; }
.connection { margin-right: 12px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.connection i { width: 7px; height: 7px; display: inline-block; margin-right: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 14px rgba(120, 198, 163, .6); }
.connection.offline i { background: var(--bad); box-shadow: none; }
@@ -73,17 +78,54 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.context-rail h2 { margin: 0; font: 400 29px/1.1 var(--serif); }
.rail-copy { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.context-form { display: grid; }
.context-status-row { margin-bottom: 16px; padding: 10px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line-soft); background: rgba(120,198,163,.04); }
.context-status-label { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; }
.context-status-row strong { color: var(--warn); font: 600 9px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.context-status-row strong[data-state="ready"] { color: var(--good); }
.context-presets { display: grid; gap: 7px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); }
.context-presets label { margin: 0; }
.context-presets select { width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 10px/1 var(--mono); }
.context-presets small { color: var(--faint); font-size: 10px; line-height: 1.35; }
.context-field { display: grid; gap: 7px; }
.context-field + .context-field { margin-top: 13px; }
.context-field label { margin: 0; }
.context-field label span { color: var(--signal); font-size: 8px; letter-spacing: .06em; }
.context-input-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 5px; }
.context-input-row input { min-width: 0; }
.copy-context { width: 34px; height: 40px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; }
.copy-context:hover { color: var(--ink); border-color: var(--faint); background: var(--panel-high); }
.id-actions { min-height: 31px; display: flex; align-items: center; gap: 7px; margin-top: 12px; }
.id-action { min-height: 30px; padding: 0 9px; border: 1px solid var(--line-soft); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; font: 9px/1 var(--mono); letter-spacing: .04em; }
.id-action:hover { color: var(--ink); border-color: var(--signal); background: rgba(201,168,95,.07); }
.icon-id-action { width: 30px; padding: 0; color: var(--faint); }
.context-form label { margin-top: 15px; }
.context-form label:first-child { margin-top: 0; }
.context-form input { height: 40px; padding: 0 10px; font: 11px/1 var(--mono); }
.context-hint { margin: 12px 0 0; color: var(--faint); font-size: 10px; line-height: 1.45; }
.advanced-context { margin-top: 17px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.advanced-context summary { padding: 12px 0; color: var(--muted); cursor: pointer; list-style: none; font: 600 9px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.advanced-context summary::before { content: "+"; display: inline-block; width: 18px; color: var(--signal); font-size: 13px; }
.advanced-context[open] summary::before { content: "−"; }
.advanced-context > div { padding: 0 0 10px; }
.advanced-context label { margin-top: 13px; }
.context-actions { display: grid; gap: 8px; }
.context-actions .secondary-button { margin-top: 18px; }
.context-actions .text-button { margin: 0; justify-self: start; padding-inline: 0; }
.secondary-button { height: 42px; margin-top: 20px; color: #11150f; background: var(--signal); font-size: 12px; }
.section-nav { display: grid; gap: 3px; margin-top: 4px; }
.section-nav-link { display: grid; grid-template-columns: 28px 1fr; gap: 2px 10px; padding: 11px 10px; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; transition: background .18s, border-color .18s, color .18s; }
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="page"] { border-left-color: var(--signal); color: var(--ink); background: rgba(201,168,95,.07); outline: 0; }
.section-nav-link span { grid-row: 1 / span 2; color: var(--signal); font: 9px/1.4 var(--mono); }
.section-nav-link b { font: 600 11px/1.2 var(--sans); }
.section-nav-link small { color: var(--faint); font: 9px/1.2 var(--mono); }
.session-stamp { margin-top: auto; padding-top: 20px; display: grid; gap: 6px; border-top: 1px solid var(--line); }
.session-stamp span { margin-top: 8px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; }
.session-stamp strong { overflow: hidden; color: var(--muted); font: 10px/1.4 var(--mono); text-overflow: ellipsis; }
.workspace { grid-column: 2; grid-row: 2; padding: clamp(28px, 4vw, 58px); min-width: 0; }
.workspace { grid-column: 2; grid-row: 2; width: min(100%, 1740px); justify-self: start; padding: 42px clamp(24px, 3vw, 48px) 64px; min-width: 0; }
.workspace > section { scroll-margin-top: 94px; }
.workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
.workspace-head h1 { margin: 0; font: 400 clamp(36px, 5vw, 58px)/1 var(--serif); letter-spacing: -.035em; }
.workspace-head h1 { margin: 0; font: 400 clamp(34px, 4vw, 50px)/1 var(--serif); letter-spacing: -.035em; }
.last-update { color: var(--faint); font: 10px/1.4 var(--mono); text-align: right; }
.signal-strip { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); }
.signal-strip div { padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-right: 1px solid var(--line); }
@@ -94,6 +136,46 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.signal-strip strong[data-state="error"] { color: var(--bad); }
.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.diagnostics-panel, .settings-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16,23,20,.82); }
.diagnostics-panel { border-color: rgba(120,198,163,.32); box-shadow: inset 3px 0 0 rgba(120,198,163,.45); }
.panel-heading { min-height: 68px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.panel-heading p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
.panel-heading h2 { margin: 0; font: 400 23px/1 var(--serif); }
.panel-heading-tools { display: flex; align-items: center; gap: 14px; }
.panel-heading-tools > span, .settings-saved { color: var(--muted); font: 9px/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.compact-button { width: auto; min-width: 142px; height: 38px; margin: 0; padding: 0 13px; }
.diagnostic-intro { padding: 13px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line-soft); color: var(--muted); }
.diagnostic-intro p { margin: 0; font-size: 11px; line-height: 1.45; }
.diagnostic-pulse { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--good); box-shadow: 0 0 12px rgba(120,198,163,.7); }
.diagnostic-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.diagnostic-list[aria-busy="true"] { opacity: .7; }
.diagnostic-item { min-width: 0; min-height: 92px; padding: 14px 16px; display: grid; grid-template-columns: 9px 1fr auto; gap: 9px; align-items: start; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.diagnostic-item:nth-child(4n) { border-right: 0; }
.diagnostic-item > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--faint); }
.diagnostic-item[data-state="ok"] > i { background: var(--good); box-shadow: 0 0 9px rgba(120,198,163,.6); }
.diagnostic-item[data-state="error"] > i { background: var(--bad); }
.diagnostic-item[data-state="loading"] > i { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.diagnostic-item strong { display: block; color: var(--ink); font: 600 10px/1.3 var(--mono); }
.diagnostic-item span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.35; }
.diagnostic-item em { color: var(--faint); font: normal 9px/1.2 var(--mono); text-transform: uppercase; }
.diagnostic-item[data-state="ok"] em { color: var(--good); }
.diagnostic-item[data-state="error"] em { color: var(--bad); }
.settings-grid { padding: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
.setting-field, .setting-check { min-width: 0; display: grid; gap: 7px; color: var(--ink); }
.setting-field > span, .setting-check b { font: 600 10px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.setting-field small, .setting-check small { color: var(--muted); font-size: 11px; line-height: 1.4; }
.setting-field select { width: 100%; min-height: 40px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 11px/1 var(--mono); }
.setting-check { grid-template-columns: 18px 1fr; align-items: start; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-soft); }
.setting-check input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--signal); }
.setting-check span { display: grid; gap: 7px; }
.settings-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; grid-column: 1 / -1; padding-top: 2px; }
.settings-actions .text-button { margin: 0; }
html[data-density="compact"] .data-card { padding: 18px; }
html[data-density="compact"] .data-card dl div { padding-block: 10px; }
html[data-density="compact"] .activity-row { min-height: 58px; padding-block: 8px; }
html[data-density="compact"] .learning-row { min-height: 54px; padding-block: 9px; }
html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, html[data-reduced-motion="true"] *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); }
.advisor-intro { align-self: center; }
.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; }
@@ -278,6 +360,9 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.advisor-panel { grid-template-columns: 1fr; }
.capability-card { grid-column: auto; }
.prospective-card { grid-column: auto; }
.diagnostic-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.diagnostic-item:nth-child(4n) { border-right: 1px solid var(--line-soft); }
.diagnostic-item:nth-child(2n) { border-right: 0; }
}
@media (max-width: 680px) {
@@ -285,14 +370,25 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.console { display: block; }
.topbar { height: 66px; padding: 0 14px; }
.wordmark small, .connection, .text-button { display: none; }
.topbar-nav { display: none; }
.context-rail { padding: 24px 18px; border-right: 0; border-bottom: 1px solid var(--line); }
.rail-copy { max-width: 48ch; }
.session-stamp { margin-top: 8px; grid-template-columns: auto 1fr; align-items: center; }
.session-stamp span { margin: 0; }
.workspace { padding: 30px 16px 50px; }
.section-nav { display: flex; gap: 6px; margin: 10px -18px -24px; padding: 10px 18px; overflow-x: auto; border-top: 1px solid var(--line-soft); background: #0e1512; scrollbar-width: thin; }
.section-nav-link { min-width: 142px; padding: 10px; border-left: 0; border-bottom: 2px solid transparent; }
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="page"] { border-left-color: transparent; border-bottom-color: var(--signal); }
.workspace-head { align-items: start; flex-direction: column; }
.last-update { text-align: left; }
.signal-strip { grid-template-columns: 1fr; }
.panel-heading { align-items: flex-start; flex-direction: column; }
.panel-heading-tools { width: 100%; justify-content: space-between; }
.diagnostic-list { grid-template-columns: 1fr; }
.diagnostic-item, .diagnostic-item:nth-child(2n), .diagnostic-item:nth-child(4n) { border-right: 0; }
.settings-grid { grid-template-columns: 1fr; padding: 16px; }
.settings-actions { grid-column: auto; align-items: stretch; flex-direction: column; }
.settings-actions .primary-button { width: 100%; }
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
.advisor-panel { padding: 20px 16px; gap: 20px; }
.advisor-controls { align-items: stretch; flex-direction: column; }
+278 -16
View File
@@ -7,14 +7,50 @@ const advisoryForm = byId("advisory-form");
const loginError = byId("login-error");
const toast = byId("toast");
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
const settingsStorageKey = "domovoy.creator.settings";
const contextPresetsStorageKey = "domovoy.creator.context.presets";
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
let toastTimer;
let refreshTimer;
let currentTask = null;
let currentProspective = null;
let pendingCommand = null;
let pendingNotification = null;
let lastAuthorizedActionID = "";
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
let uiSettings = {density: "comfortable", refresh: "off", motion: false};
const sectionNavLinks = Array.from(document.querySelectorAll(".section-nav-link"));
const cabinetSections = ["overview", "diagnostics", "operations", "settings"]
.map((id) => byId(id))
.filter(Boolean);
function setActiveSection(id) {
sectionNavLinks.forEach((link) => {
const active = link.getAttribute("href") === `#${id}`;
link.classList.toggle("active", active);
if (active) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
});
}
if ("IntersectionObserver" in window) {
const sectionObserver = new IntersectionObserver((entries) => {
const visible = entries.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setActiveSection(visible.target.id);
}, {rootMargin: "-18% 0px -64% 0px", threshold: [0, .25, .6, 1]});
cabinetSections.forEach((section) => sectionObserver.observe(section));
}
sectionNavLinks.forEach((link) => link.addEventListener("click", () => {
const id = link.getAttribute("href").slice(1);
setActiveSection(id);
}));
window.addEventListener("hashchange", () => {
const id = window.location.hash.slice(1);
if (cabinetSections.some((section) => section.id === id)) setActiveSection(id);
});
function setText(id, value, fallback = "—") {
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
@@ -39,6 +75,18 @@ function notify(message) {
toastTimer = setTimeout(() => { toast.hidden = true; }, 4200);
}
function safeClientError(error) {
const message = error && typeof error.message === "string" ? error.message : "";
switch (message) {
case "creator authentication denied":
return "Логин или пароль не принят.";
case "Сессия отклонена":
return "Сессия истекла. Войдите снова.";
default:
return "Операция недоступна. Попробуйте ещё раз.";
}
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
@@ -50,7 +98,9 @@ async function request(path, options = {}) {
}
if (!response.ok) {
const detail = (await response.text()).trim();
throw new Error(detail || `Ошибка ${response.status}`);
const error = new Error(detail || `Ошибка ${response.status}`);
error.status = response.status;
throw error;
}
return response.json();
}
@@ -64,15 +114,57 @@ function restoreContext() {
} catch (_) {
localStorage.removeItem("domovoy.creator.context");
}
renderContextState();
renderContextPresets();
}
function saveContext() {
const saved = {};
contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); });
localStorage.setItem("domovoy.creator.context", JSON.stringify(saved));
renderContextState();
return saved;
}
function readContextPresets() {
try {
const value = JSON.parse(localStorage.getItem(contextPresetsStorageKey) || "[]");
return Array.isArray(value) ? value.filter((item) => item && item.session && item.workspace) : [];
} catch (_) {
localStorage.removeItem(contextPresetsStorageKey);
return [];
}
}
function renderContextState() {
const state = byId("context-state");
if (!state) return;
const ready = Boolean(byId("session-id").value.trim() && byId("workspace-id").value.trim());
state.textContent = ready ? "готов к проверке" : "не выбран";
state.dataset.state = ready ? "ready" : "empty";
}
function renderContextPresets() {
const select = byId("context-preset");
if (!select) return;
const presets = readContextPresets();
select.replaceChildren(new Option("Выберите сохранённый набор…", ""));
presets.forEach((preset, index) => {
const session = preset.session.length > 18 ? `${preset.session.slice(0, 8)}…${preset.session.slice(-6)}` : preset.session;
const workspace = preset.workspace.length > 18 ? `${preset.workspace.slice(0, 8)}…${preset.workspace.slice(-6)}` : preset.workspace;
select.appendChild(new Option(`${session} · ${workspace}`, String(index)));
});
}
function rememberContextPreset(context) {
if (!context["session-id"] || !context["workspace-id"]) return;
const next = {session: context["session-id"], workspace: context["workspace-id"], savedAt: Date.now()};
const presets = readContextPresets().filter((item) => item.session !== next.session || item.workspace !== next.workspace);
presets.unshift(next);
localStorage.setItem(contextPresetsStorageKey, JSON.stringify(presets.slice(0, 5)));
renderContextPresets();
}
async function showConsole() {
const session = await request("/api/session/current");
setText("session-actor", session.ActorID);
@@ -80,6 +172,7 @@ async function showConsole() {
setText("session-expiry", expiry);
loginView.hidden = true;
consoleView.hidden = false;
restoreSettings();
restoreContext();
await loadAdvisoryPolicy();
const context = saveContext();
@@ -88,10 +181,13 @@ async function showConsole() {
loadActionHistory();
loadLearning();
}
runDiagnostics();
}
function endSession(message = "Сессия завершена.") {
sessionToken = "";
clearInterval(refreshTimer);
refreshTimer = null;
sessionStorage.removeItem("domovoy.creator.session");
consoleView.hidden = true;
loginView.hidden = false;
@@ -128,7 +224,7 @@ loginForm.addEventListener("submit", async (event) => {
byId("creator-password").value = "";
await showConsole();
} catch (error) {
loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message;
loginError.textContent = safeClientError(error);
} finally {
button.disabled = false;
button.firstElementChild.textContent = "Открыть консоль";
@@ -144,14 +240,129 @@ byId("toggle-secret").addEventListener("click", () => {
contextForm.addEventListener("submit", (event) => {
event.preventDefault();
saveContext();
const context = saveContext();
rememberContextPreset(context);
loadDashboard();
});
for (const input of [byId("session-id"), byId("workspace-id")]) {
input.addEventListener("input", renderContextState);
}
byId("context-preset").addEventListener("change", (event) => {
const index = Number(event.target.value);
if (!Number.isInteger(index)) return;
const preset = readContextPresets()[index];
if (!preset) return;
byId("session-id").value = preset.session;
byId("workspace-id").value = preset.workspace;
renderContextState();
loadDashboard();
notify("Контекст из недавнего набора загружен.");
});
for (const button of document.querySelectorAll("[data-copy-context]")) {
button.addEventListener("click", async () => {
const value = byId(button.dataset.copyContext).value.trim();
if (!value) {
notify("Сначала укажите идентификатор.");
return;
}
try {
await navigator.clipboard.writeText(value);
notify("Идентификатор скопирован.");
} catch (_) {
notify("Не удалось скопировать идентификатор.");
}
});
}
for (const button of document.querySelectorAll("[data-copy-value], [data-use-value]")) {
button.addEventListener("click", async () => {
const source = byId(button.dataset.copyValue || button.dataset.useValue);
const value = source?.textContent.trim() || "";
if (!value || value === "—") {
notify("Сначала загрузите значение.");
return;
}
if (button.dataset.useValue) {
const target = byId(button.dataset.targetId);
if (!target) return;
target.value = value;
document.querySelector(".advanced-context").open = true;
saveContext();
notify("ID подставлен в расширенную проверку.");
return;
}
try {
await navigator.clipboard.writeText(value);
notify("ID скопирован.");
} catch (_) {
notify("Не удалось скопировать ID.");
}
});
}
byId("reset-context").addEventListener("click", () => {
contextFields.forEach((id) => { byId(id).value = id === "capability-id" ? "cognitive-proposal" : ""; });
localStorage.removeItem("domovoy.creator.context");
loadDashboard();
notify("Рабочий контекст сброшен.");
});
function restoreSettings() {
try {
const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}");
uiSettings = {
density: ["comfortable", "compact"].includes(saved.density) ? saved.density : "comfortable",
refresh: ["off", "30", "60", "300"].includes(saved.refresh) ? saved.refresh : "off",
motion: saved.motion === true,
};
} catch (_) {
uiSettings = {density: "comfortable", refresh: "off", motion: false};
}
byId("setting-density").value = uiSettings.density;
byId("setting-refresh").value = uiSettings.refresh;
byId("setting-motion").checked = uiSettings.motion;
applySettings();
}
function applySettings() {
document.documentElement.dataset.density = uiSettings.density;
document.documentElement.dataset.reducedMotion = uiSettings.motion ? "true" : "false";
clearInterval(refreshTimer);
refreshTimer = null;
if (sessionToken && uiSettings.refresh !== "off") {
refreshTimer = setInterval(() => loadDashboard(), Number(uiSettings.refresh) * 1000);
}
}
byId("settings-form").addEventListener("submit", (event) => {
event.preventDefault();
uiSettings = {
density: byId("setting-density").value,
refresh: byId("setting-refresh").value,
motion: byId("setting-motion").checked,
};
localStorage.setItem(settingsStorageKey, JSON.stringify(uiSettings));
applySettings();
setText("settings-saved", "Сохранено только в этом браузере");
notify("Настройки кабинета сохранены.");
});
byId("reset-settings").addEventListener("click", () => {
uiSettings = {density: "comfortable", refresh: "off", motion: false};
localStorage.removeItem(settingsStorageKey);
restoreSettings();
setText("settings-saved", "Восстановлены значения по умолчанию");
notify("Настройки сброшены.");
});
byId("refresh-all").addEventListener("click", loadDashboard);
byId("refresh-actions").addEventListener("click", loadActionHistory);
byId("refresh-learning").addEventListener("click", loadLearning);
byId("logout").addEventListener("click", () => endSession());
byId("run-diagnostics").addEventListener("click", runDiagnostics);
advisoryForm.addEventListener("submit", async (event) => {
event.preventDefault();
@@ -185,7 +396,7 @@ advisoryForm.addEventListener("submit", async (event) => {
byId("advisory-result").hidden = false;
} catch (error) {
setText("advisory-state", "ОШИБКА");
setText("advisory-text", error.message);
setText("advisory-text", safeClientError(error));
byId("advisory-result").hidden = false;
} finally {
byId("advisory-egress-ack").checked = false;
@@ -246,6 +457,57 @@ async function loadDashboard() {
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
}
async function runDiagnostics() {
const list = byId("diagnostic-list");
const summary = byId("diagnostic-summary");
const checks = [
["Сессия", "identity", () => request("/api/session/current")],
["Политика совета", "advisory", () => request("/api/advisory-policy")],
["Когнитивная возможность", "capability", () => request("/api/capabilities/cognitive-proposal/health")],
["Сегодня", "today", () => request("/api/today")],
["Внимание", "attention", () => request("/api/attention")],
["Действия", "actions", () => request("/api/actions?limit=1")],
["Learning evidence", "learning", () => request("/api/learning/recent?limit=1")],
];
list.setAttribute("aria-busy", "true");
list.replaceChildren();
checks.forEach(([label, key]) => list.append(createDiagnosticItem(label, key, "loading")));
setText("diagnostic-summary", "Проверяю…");
const results = await Promise.all(checks.map(async ([label, key, run]) => {
const started = performance.now();
try {
await run();
return {label, key, state: "ok", detail: `Ответ получен · ${Math.round(performance.now() - started)} мс`};
} catch (error) {
const detail = error?.status === 400 ? "Нужны параметры рабочего контекста" : error?.status === 401 ? "Сессия истекла" : error?.status >= 500 ? "Сервис временно недоступен" : "Проверка не пройдена";
return {label, key, state: "error", detail};
}
}));
list.replaceChildren(...results.map((result) => createDiagnosticItem(result.label, result.key, result.state, result.detail)));
list.setAttribute("aria-busy", "false");
const passed = results.filter((result) => result.state === "ok").length;
setText("diagnostic-summary", `${passed}/${results.length} проверок пройдено`);
}
function createDiagnosticItem(label, key, state, detail = "Проверяю…") {
const item = document.createElement("article");
item.className = "diagnostic-item";
item.dataset.state = state;
item.dataset.check = key;
const marker = document.createElement("i");
marker.setAttribute("aria-hidden", "true");
const copy = document.createElement("div");
const title = document.createElement("strong");
title.textContent = label;
const description = document.createElement("span");
description.textContent = detail;
copy.append(title, description);
const status = document.createElement("em");
status.textContent = state === "ok" ? "OK" : state === "error" ? "ошибка" : "…";
item.append(marker, copy, status);
return item;
}
async function loadLearning() {
const experienceList = byId("experience-list");
const reflectionList = byId("reflection-list");
@@ -272,7 +534,7 @@ async function loadLearning() {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = error.message;
failed.textContent = safeClientError(error);
list.append(failed);
});
setText("learning-count", "ОШИБКА");
@@ -371,7 +633,7 @@ async function loadActionHistory() {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = error.message;
failed.textContent = safeClientError(error);
list.append(failed);
setText("action-count", "ОШИБКА");
} finally {
@@ -430,7 +692,7 @@ async function reconcileHistoryAction(actionID, button) {
await loadActionHistory();
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(error.message);
notify(safeClientError(error));
button.disabled = false;
button.textContent = "Сверить";
}
@@ -450,7 +712,7 @@ async function loadFocus(query) {
} catch (error) {
setSignal("focus", "ошибка", "error");
setBadge("focus", "НЕДОСТУПЕН", "bad");
setText("focus-message", error.message);
setText("focus-message", safeClientError(error));
byId("connection-state").classList.add("offline");
}
}
@@ -472,7 +734,7 @@ async function loadTask(taskID, query) {
currentTask = null;
setSignal("task", "отклонена", "error");
setBadge("task", "ВНЕ ФОКУСА", "bad");
setText("task-message", error.message);
setText("task-message", safeClientError(error));
updateActionReadiness();
}
}
@@ -511,12 +773,12 @@ async function loadProspectiveRegister(selectedID) {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty";
failed.textContent = error.message;
failed.textContent = safeClientError(error);
list.append(failed);
setText("prospective-count", "ОШИБКА");
setSignal("prospective", "ошибка", "error");
setBadge("prospective", "НЕДОСТУПНА", "bad");
setText("prospective-message", error.message);
setText("prospective-message", safeClientError(error));
}
}
@@ -572,7 +834,7 @@ async function loadProspective(prospectiveID) {
setSignal("prospective", "ошибка", "error");
setBadge("prospective", "НЕДОСТУПНА", "bad");
setText("prospective-state", "UNKNOWN");
setText("prospective-message", error.message);
setText("prospective-message", safeClientError(error));
updateActionReadiness();
}
}
@@ -594,7 +856,7 @@ async function loadCapability(capabilityID) {
setSignal("capability", "ошибка", "error");
setBadge("capability", "НЕДОСТУПНА", "bad");
setText("capability-state", "UNKNOWN");
setText("capability-reason", error.message);
setText("capability-reason", safeClientError(error));
}
}
@@ -700,7 +962,7 @@ byId("confirm-notification").addEventListener("click", async () => {
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
} catch (error) {
setText("notification-confirmation-error", error.message);
setText("notification-confirmation-error", safeClientError(error));
} finally {
button.disabled = false;
button.textContent = "Подтверждаю отправку";
@@ -730,7 +992,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => {
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
await loadProspectiveRegister(command.ProspectiveItemID);
} catch (error) {
setText("confirmation-error", error.message);
setText("confirmation-error", safeClientError(error));
} finally {
button.disabled = false;
button.textContent = "Подтверждаю переход";
@@ -764,7 +1026,7 @@ byId("reconcile-action").addEventListener("click", async () => {
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(error.message);
notify(safeClientError(error));
} finally {
button.disabled = false;
button.textContent = "Проверить исход";
+79 -15
View File
@@ -6,9 +6,10 @@
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#101714">
<title>Домовой — консоль создателя</title>
<link rel="stylesheet" href="/assets/console.css">
<link rel="stylesheet" href="/assets/console.css?v=20260821-context">
</head>
<body>
<a class="skip-link" href="#overview">К содержимому</a>
<div class="grain" aria-hidden="true"></div>
<main id="login-view" class="login-shell">
@@ -44,6 +45,12 @@
</a>
<div class="topbar-actions">
<span id="connection-state" class="connection"><i></i>система доступна</span>
<nav class="topbar-nav" aria-label="Разделы кабинета">
<a href="#overview">Обзор</a>
<a href="#diagnostics">Проверки</a>
<a href="#operations">Операции</a>
<a href="#settings">Настройки</a>
</nav>
<button id="refresh-all" class="text-button" type="button">Обновить</button>
<button id="logout" class="icon-button" type="button" aria-label="Завершить сессию" title="Выйти">↗</button>
</div>
@@ -56,18 +63,45 @@
<p class="rail-copy">Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.</p>
</div>
<form id="context-form" class="context-form">
<label for="session-id">Идентификатор сессии</label>
<input id="session-id" autocomplete="off" placeholder="session-id">
<label for="workspace-id">Рабочая область</label>
<input id="workspace-id" autocomplete="off" placeholder="workspace-id">
<label for="task-id">Задача</label>
<input id="task-id" autocomplete="off" placeholder="task-id">
<label for="prospective-id">Выбранное ожидание</label>
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
<label for="capability-id">Возможность</label>
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
<button class="secondary-button" type="submit">Загрузить контекст</button>
<div class="context-status-row">
<span class="context-status-label">ТЕКУЩИЙ КОНТЕКСТ</span>
<strong id="context-state" data-state="empty">не выбран</strong>
</div>
<div class="context-presets">
<label for="context-preset">Недавние наборы</label>
<select id="context-preset">
<option value="">Выберите сохранённый набор…</option>
</select>
<small>Сохраняются только в этом браузере.</small>
</div>
<div class="context-field">
<label for="session-id">Идентификатор сессии <span>обязательно</span></label>
<div class="context-input-row"><input id="session-id" autocomplete="off" spellcheck="false" placeholder="Вставьте session-id"><button class="copy-context" data-copy-context="session-id" type="button" aria-label="Скопировать идентификатор сессии" title="Скопировать">⧉</button></div>
</div>
<div class="context-field">
<label for="workspace-id">Рабочая область <span>обязательно</span></label>
<div class="context-input-row"><input id="workspace-id" autocomplete="off" spellcheck="false" placeholder="Вставьте workspace-id"><button class="copy-context" data-copy-context="workspace-id" type="button" aria-label="Скопировать рабочую область" title="Скопировать">⧉</button></div>
</div>
<p class="context-hint">Для «Состояния» нужны только эти два значения. Задача, ожидание и возможность находятся ниже в расширенных полях.</p>
<details class="advanced-context">
<summary>Расширенные идентификаторы</summary>
<div>
<label for="task-id">Задача</label>
<input id="task-id" autocomplete="off" placeholder="task-id">
<label for="prospective-id">Выбранное ожидание</label>
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
<label for="capability-id">Возможность</label>
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
</div>
</details>
<div class="context-actions"><button class="secondary-button" type="submit">Загрузить контекст</button><button id="reset-context" class="text-button" type="button">Сбросить</button></div>
</form>
<nav class="section-nav" aria-label="Навигация по кабинету">
<a class="section-nav-link active" aria-current="page" href="#overview"><span>01</span><b>Состояние</b><small>фокус и сигналы</small></a>
<a class="section-nav-link" href="#diagnostics"><span>02</span><b>Проверки</b><small>доступность и границы</small></a>
<a class="section-nav-link" href="#operations"><span>03</span><b>Операции</b><small>ожидания и действия</small></a>
<a class="section-nav-link" href="#settings"><span>04</span><b>Настройки</b><small>интерфейс и обновление</small></a>
</nav>
<div class="session-stamp">
<span>ACTOR</span>
<strong id="session-actor">—</strong>
@@ -77,7 +111,7 @@
</aside>
<main class="workspace">
<section class="workspace-head">
<section id="overview" class="workspace-head">
<div>
<p class="eyebrow">операционная картина</p>
<h1>Состояние сейчас</h1>
@@ -92,6 +126,20 @@
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
</section>
<section id="diagnostics" class="diagnostics-panel" aria-labelledby="diagnostics-title">
<header class="panel-heading">
<div><p>Проверяемая система</p><h2 id="diagnostics-title">Диагностика кабинета</h2></div>
<div class="panel-heading-tools"><span id="diagnostic-summary">Проверка ещё не запускалась</span><button id="run-diagnostics" class="secondary-button compact-button" type="button">Проверить всё</button></div>
</header>
<div class="diagnostic-intro">
<span class="diagnostic-pulse" aria-hidden="true"></span>
<p>Здесь видны только ответы текущей авторизованной сессии. Проверка не меняет данные и помогает отличить отсутствие контекста от недоступности сервиса.</p>
</div>
<div id="diagnostic-list" class="diagnostic-list" aria-live="polite" aria-busy="false">
<p class="register-empty">Нажмите «Проверить всё», чтобы получить актуальный снимок.</p>
</div>
</section>
<section class="advisor-panel" aria-labelledby="advisor-title">
<div class="advisor-intro">
<p>COGNITIVE / ADVISORY V1</p>
@@ -120,7 +168,7 @@
</div>
</section>
<section class="register-panel" aria-labelledby="register-title">
<section id="operations" class="register-panel" aria-labelledby="register-title">
<header>
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
<span id="prospective-count">0 ЗАПИСЕЙ</span>
@@ -167,6 +215,19 @@
</div>
</section>
<section id="settings" class="settings-panel" aria-labelledby="settings-title">
<header class="panel-heading">
<div><p>Локальные предпочтения</p><h2 id="settings-title">Настройки рабочего кабинета</h2></div>
<span id="settings-saved" class="settings-saved">Сохраняются в этом браузере</span>
</header>
<form id="settings-form" class="settings-grid">
<label class="setting-field"><span>Плотность данных</span><small>Выберите, сколько строк помещается на экране.</small><select id="setting-density"><option value="comfortable">Комфортная</option><option value="compact">Компактная</option></select></label>
<label class="setting-field"><span>Автообновление</span><small>Повторная проверка состояния без изменения данных.</small><select id="setting-refresh"><option value="off">Выключено</option><option value="30">Каждые 30 секунд</option><option value="60">Каждую минуту</option><option value="300">Каждые 5 минут</option></select></label>
<label class="setting-check"><input id="setting-motion" type="checkbox"><span><b>Сократить анимацию</b><small>Оставить спокойный режим для длительной работы.</small></span></label>
<div class="settings-actions"><button id="save-settings" class="primary-button compact-button" type="submit"><span>Сохранить настройки</span><span aria-hidden="true">→</span></button><button id="reset-settings" class="text-button" type="button">Сбросить</button></div>
</form>
</section>
<section class="data-grid">
<article class="data-card focus-card">
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
@@ -188,6 +249,7 @@
<div><dt>Ревизия</dt><dd id="task-revision">—</dd></div>
</dl>
<p id="task-message" class="card-message">Укажите задачу для проверки относительно активного фокуса.</p>
<div class="id-actions"><button class="id-action" data-use-value="task-value" data-target-id="task-id" type="button">Использовать ID задачи</button><button class="id-action icon-id-action" data-copy-value="task-value" type="button" aria-label="Скопировать ID задачи" title="Скопировать ID">⧉</button></div>
</article>
<article class="data-card prospective-card">
@@ -205,6 +267,7 @@
<div><dt>Ревизия</dt><dd id="prospective-revision">—</dd></div>
</dl>
<p id="prospective-message" class="card-message">Укажите идентификатор ProspectiveItem для проверки владельца и состояния.</p>
<div class="id-actions"><button class="id-action" data-use-value="prospective-value" data-target-id="prospective-id" type="button">Использовать ID ожидания</button><button class="id-action icon-id-action" data-copy-value="prospective-value" type="button" aria-label="Скопировать ID ожидания" title="Скопировать ID">⧉</button></div>
<section class="commit-zone" aria-labelledby="commit-zone-title">
<div class="commit-zone-copy">
<p>Commit / контролируемый переход</p>
@@ -240,6 +303,7 @@
<div><dt>Ревизия</dt><dd id="capability-revision">—</dd></div>
<div><dt>Наблюдалось</dt><dd id="capability-observed">—</dd></div>
</dl>
<div class="id-actions"><button class="id-action" data-use-value="capability-value" data-target-id="capability-id" type="button">Использовать ID возможности</button><button class="id-action icon-id-action" data-copy-value="capability-value" type="button" aria-label="Скопировать ID возможности" title="Скопировать ID">⧉</button></div>
</article>
</section>
</main>
@@ -286,6 +350,6 @@
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/assets/console.js" defer></script>
<script src="/assets/console.js?v=20260821-context" defer></script>
</body>
</html>
+5 -5
View File
@@ -8,8 +8,8 @@ services:
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-assistant}
POSTGRES_USER: ${POSTGRES_SUPERUSER}
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD}
POSTGRES_USER: ${POSTGRES_SUPERUSER:?POSTGRES_SUPERUSER is required}
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?POSTGRES_SUPERUSER_PASSWORD is required}
CONTINUITY_DB_PASSWORD: ${CONTINUITY_DB_PASSWORD}
IDENTITY_DB_PASSWORD: ${IDENTITY_DB_PASSWORD}
AUTHORITY_DB_PASSWORD: ${AUTHORITY_DB_PASSWORD}
@@ -23,7 +23,7 @@ services:
- assistant_pg:/var/lib/postgresql
- assistant_core_secrets:/run/secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_SUPERUSER} -d ${POSTGRES_DB:-assistant}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_SUPERUSER:?POSTGRES_SUPERUSER is required} -d ${POSTGRES_DB:-assistant}"]
interval: 10s
timeout: 5s
retries: 20
@@ -95,7 +95,7 @@ services:
POSTGRES_USER: continuity_role
POSTGRES_PASSWORD: ${CONTINUITY_DB_PASSWORD}
CORE_RUNTIME_URL: http://core-runtime:8080
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN:?COMMIT_EXECUTOR_TOKEN is required}
commit-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
@@ -111,7 +111,7 @@ services:
AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD}
CORE_RUNTIME_URL: http://core-runtime:8080
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN:?COMMIT_EXECUTOR_TOKEN is required}
NOTIFICATION_WRITE_TOKEN_FILE: /run/notification-writer/token
NOTIFICATION_WRITE_SECRET_REF: domovoy-notification-write-v1
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000072-semantic-interpretation-deferral-boundary.json",
"task_id": "DEV-000072",
"task_file": "dev/tasks/DEV-000074-creator-cabinet-operations.json",
"task_id": "DEV-000074",
"task_revision": 1
}
@@ -0,0 +1,96 @@
{
"task_id": "DEV-000073",
"task_revision": 1,
"architecture_revision": "DEV-000072-baseline",
"base_repo_revision": "da94ed1",
"risk_tier": "E",
"intent": "Make Assistant production Compose fail closed when required PostgreSQL and executor inputs are absent, without mutating neighboring projects or storing secrets in the repository.",
"requirement_ids": [
"Production missing inputs must fail before container startup",
"Assistant-only deployment scope",
"No secret values in Git",
"No neighboring project mutation"
],
"allowed_change_scope": {
"modules": [],
"paths": [
"deploy/docker-compose.prod.yml",
"docs/PROJECT_STATUS.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000073-production-compose-fail-closed.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/**",
"security/**",
"modules/**",
"contracts/**",
"database/migrations/**",
"deploy/docker-compose.dev.yml",
".gitea/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_provider_adapters": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": [
"existing production Compose topology",
"existing external secret injection"
],
"may_change": []
},
"semantic_owner": "none-production-configuration",
"canonical_state": [],
"state_transitions": [
"missing required production input -> Compose validation failure",
"all required inputs present -> existing Assistant stack topology remains unchanged"
],
"side_effects_and_privileges": [
"No secret creation or storage in Git",
"No deployment of neighboring projects",
"Promotion only after exact artifact and health checks"
],
"persistence_and_migrations": [
"No database migration",
"No canonical state change"
],
"failure_retry_restart": [
"Missing required input fails before Compose startup",
"Existing Assistant containers remain untouched until explicit promotion"
],
"assumptions": [
"Production control plane is docker-prod / docker.cin.su",
"Required secret values remain external to the repository",
"Neighboring Docker projects must not be included in any Compose operation"
],
"acceptance_scenarios": [
"Compose rejects missing POSTGRES_SUPERUSER",
"Compose rejects missing POSTGRES_SUPERUSER_PASSWORD",
"Compose rejects missing COMMIT_EXECUTOR_TOKEN",
"Compose renders with synthetic non-secret values",
"scope check passes",
"Assistant-only production inspection identifies exact artifact and health state"
],
"required_tests": [
"docker compose config -q with missing inputs",
"docker compose config -q with synthetic inputs",
"go test ./...",
"go vet ./...",
"go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
"go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
"go run ./cmd/devctl check-architecture",
"production Assistant-only health and artifact inspection"
],
"terminal_gap_states": [
"BLOCKED_BY_MISSING_CAPABILITY",
"BLOCKED_BY_CONFLICT",
"REQUIRES_MIGRATION_DECISION"
]
}
@@ -0,0 +1,92 @@
{
"task_id": "DEV-000074",
"task_revision": 1,
"architecture_revision": "DEV-000073-baseline",
"base_repo_revision": "407275c",
"risk_tier": "E",
"intent": "Make the authenticated Creator Web cabinet understandable and operable for a human by adding section navigation, read-only diagnostics, and browser-local interface preferences without changing domain state or authority boundaries.",
"requirement_ids": [
"Human can move between overview, diagnostics, operations and settings",
"Human can run all currently exposed read-only Creator checks from one diagnostic panel",
"Interface preferences remain local to the browser and never change canonical state",
"Existing explicit confirmation remains required for canonical mutations",
"Desktop and mobile layouts remain usable"
],
"allowed_change_scope": {
"modules": ["cmd/creator-web"],
"paths": [
"cmd/creator-web/static/index.html",
"cmd/creator-web/static/assets/console.css",
"cmd/creator-web/static/assets/console.js",
"cmd/creator-web/server_test.go",
"cmd/creator-web/chat_test.go",
"docs/PROJECT_STATUS.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000074-creator-cabinet-operations.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/**",
"security/**",
"database/**",
"modules/**",
"contracts/**",
"adapters/**",
"cmd/core-runtime/**",
"cmd/commit-runtime/**",
"cmd/continuity-runtime/**",
"deploy/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_provider_adapters": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": ["existing authenticated Creator gateway endpoints"],
"may_change": []
},
"semantic_owner": "none-interface-adapter",
"canonical_state": [],
"state_transitions": [
"diagnostic request -> read-only gateway observation",
"settings form submit -> browser-local preference update"
],
"side_effects_and_privileges": [
"No new privilege or mutation endpoint",
"No database migration",
"Existing Commit actions retain explicit confirmation dialogs",
"No deployment or neighboring project mutation"
],
"persistence_and_migrations": ["No canonical persistence; localStorage only for interface preferences"],
"failure_retry_restart": ["Diagnostic failures are shown per check without exposing upstream error details"],
"assumptions": [
"Existing Creator API routes remain the source of truth",
"Browser-local settings are disposable and do not affect domain behavior"
],
"acceptance_scenarios": [
"Authenticated cabinet exposes navigation to all four human-facing sections",
"read-only diagnostics checks session, advisory policy, capability, today, attention, actions and learning",
"density, auto-refresh and reduced-motion preferences save and reset locally",
"mobile layout remains readable",
"existing Creator Web tests pass",
"architecture and diff scope checks pass"
],
"required_tests": [
"node --check cmd/creator-web/static/assets/console.js",
"go test ./cmd/creator-web",
"go test ./...",
"go vet ./...",
"go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
"go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
"go run ./cmd/devctl check-architecture",
"git diff --check",
"responsive browser inspection"
],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
+36
View File
@@ -2,6 +2,42 @@
Audit date: 2026-08-17. Active implementation: DEV-000072 revision 1.
## Production recovery checkpoint — 2026-08-21
- `RECOVERY_ACTION = assistant-only docker compose up -d --no-build using ignored local secrets/docker-prod-bootstrap.env; no build, prune, volume deletion or neighbor-stack command`
- `RECOVERY_RESULT = PASS: assistant-creator-web healthy on host port 18080; assistant-postgres healthy; core-runtime, continuity-runtime, commit-runtime and cognitive-worker running; https://assistant.cin.su/healthz returned HTTP 200`
- `ROUTING_FINDING = Caddy already routes assistant.cin.su to host.docker.internal:18080; the 502 was caused by creator-web being Created with no listener, not by a bad Caddy port`
- `PRODUCTION_ARTIFACT_AFTER_RECOVERY = revision 2e0c0236aa29d493d1ee9579354c244029cea09a on inspected Assistant containers; CURRENT_HEAD remains local and is not promoted`
- `NEIGHBOR_SCOPE = read-only before/after snapshots showed atlas, llm-local, adapter-1c, gitea, portainer and Caddy still running; no neighbor container was targeted`
## Creator cabinet operations checkpoint — 2026-08-21
- `TASK = DEV-000074 Creator cabinet operations`
- `UI_CHANGE = human-facing navigation for overview, diagnostics, operations and settings; read-only gateway diagnostics; browser-local density, auto-refresh and reduced-motion preferences`
- `UI_VALIDATION = PASS: node --check console.js; go test ./cmd/creator-web; go test ./...; go vet ./...; devctl preflight; check-diff; check-architecture; git diff --check; browser console had no errors on static smoke`
- `PRODUCTION_PROMOTION = Assistant-only creator-web image built from revision 3ed64631f6a0defbf90c7981179f99d26de34ca9 and started with no dependency recreation`
- `PRODUCTION_UI_EVIDENCE = https://assistant.cin.su/ HTTP 200 and deployed HTML contains diagnostics/settings markers; creator-web healthy on 18080; healthz HTTP 200`
- `NEIGHBOR_SCOPE = atlas, llm-local, adapter-1c, gitea, portainer and Caddy remained running; no neighbor image or container was targeted`
- `VISUAL_HOTFIX = 2026-08-21: constrained workspace to 1740px on wide screens, raised contrast and reduced grain, made diagnostics explain HTTP failure classes; creator-web revision 4226c986d70af017ecfbb8755e0d7a44bc189fa is healthy`
- `DIAGNOSTIC_RECOVERY = 2026-08-21: synchronized eight Assistant PostgreSQL role passwords from the ignored production bootstrap secret without changing data; authenticated session, advisory policy, capability health, today, attention, actions and learning endpoints all returned HTTP 200`
- `CONTEXT_UX = 2026-08-21: primary session/workspace inputs remain visible; task, prospective item and capability IDs moved into an expandable advanced section; browser-local context reset added; cache-busted assets served by creator-web revision 93b0965485e8ba9ca85613a71d137f3f35203376`
## Post-audit local repair checkpoint — 2026-08-21
- `CURRENT_BRANCH = codex/domovoy-understanding-repair-v1`
- `CURRENT_HEAD = 3aa0a1d` (10 local repair/config commits ahead of `origin/codex/domovoy-understanding-repair-v1`)
- `ACTIVE_TASK = DEV-000073 production Compose fail-closed configuration`
- `LOCAL_VALIDATION = PASS: go test ./...; go vet ./...; devctl preflight; check-diff; check-architecture; git diff --check; node --check for creator-web chat.js and console.js; docker compose config with missing-input rejection and synthetic-input render`
- `REPAIRS = selected semantic candidate preservation; cognitive gateway candidate validation; cognitive deferral planning precedence; safe creator-web and creator-console error rendering; fail-closed production Compose inputs`
- `PRODUCTION_STATUS = recovered for existing production artifact; not verified for CURRENT_HEAD; no current-branch promotion, migration application or live /chat acceptance is claimed here`
- `NEXT_REQUIRED_EVIDENCE = promote the exact evaluated artifact, apply migration 027 if absent, then verify production health and authenticated /chat acceptance`
- `PROMOTION_ACCESS = Portainer API key remains unavailable; recovery used the authorized Docker production context and ignored local secret file without printing or copying its values`
- `CURRENT_PRODUCTION_READONLY_AUDIT = 2026-08-21 after recovery: https://assistant.cin.su/healthz returned HTTP 200; assistant-postgres-1 and assistant-creator-web-1 healthy; all Assistant runtime containers running`
- `CURRENT_PRODUCTION_BLOCKERS = current branch 3aa0a1d is not the deployed artifact; authenticated /chat acceptance remains pending`
- `PRODUCTION_BLOCKER_SCOPE = exact current-branch promotion and live acceptance remain outstanding; no neighbor stack was mutated`
- `CURRENT_PRODUCTION_ARTIFACT = revision 2e0c0236aa29d493d1ee9579354c244029cea09a on all inspected project-built containers; CURRENT_HEAD is not promoted`
- `CURRENT_PRODUCTION_SCHEMA = migration 027 is present: interaction.semantic_candidates, interaction.semantic_candidate_capability_hints, and interpretation columns secondary_speech_act/applicability/selection_reason were confirmed read-only`
## DEV-000072 checkpoint
- `DEV_000072_STATUS = LOCAL_ACCEPTANCE_PASS`
+169 -111
View File
@@ -127,19 +127,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter
scores := semanticScores(tokens, hasQuestionMark, corrects != "")
primary, primaryScore := topAct(scores)
secondary, secondaryScore := secondAct(scores, primary)
if (startsWithModalRequest(tokens) || startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж")) &&
(primary == interaction.SpeechActAssertion || primary == interaction.SpeechActDirective) &&
(secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate) {
primary, secondary = secondary, primary
}
if primary == interaction.SpeechActQuestion && !hasQuestionMark && !containsMeaningfulInterrogative(tokens) &&
!startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж") {
if len(tokens) > 0 && containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") {
primary, secondary = interaction.SpeechActDirective, interaction.SpeechActUnknown
} else {
primary, secondary = interaction.SpeechActAssertion, interaction.SpeechActUnknown
}
}
candidate := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(id),
UtteranceID: "",
@@ -159,10 +146,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter
if candidate.PrimarySpeechAct == interaction.SpeechActUnknown {
candidate.PrimarySpeechAct = interaction.SpeechActAssertion
}
if startsWithDirectiveWhatNibud(tokens) {
candidate.NeedsCognition = false
candidate.CapabilityHints = removeCapabilityHint(candidate.CapabilityHints, "COGNITIVE_REASONING")
}
if candidate.Applicability == interaction.SemanticCandidateClarify {
candidate.Clarification = interaction.ClarificationRequired
candidate.NeedsCognition = false
@@ -192,7 +175,8 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
interaction.SpeechActConversational: 0,
}
if hasQuestionMark {
scores[interaction.SpeechActQuestion]++
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate]++
}
if explicitCorrection {
scores[interaction.SpeechActCorrection] += 6
@@ -209,24 +193,26 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
scores[interaction.SpeechActConfirmation] += 2
}
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "подум", "понят", "посмотри"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж"}
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй", "добрый"}
investigateStems := []string{"разбер", "проверь", "провери", "проанализ", "исслед", "выясн", "узнай"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "поменя", "измен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "поправ", "обнов", "ответь", "посмотри", "проверь"}
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
decisionStems := []string{"решил", "решение", "выбираю"}
arithmeticMarker := false
for index, token := range tokens {
if firstPersonPastReport(tokens, index) && containsStem(token, directiveStems...) {
continue
}
if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") {
scores[interaction.SpeechActQuestion] += 3
scores[interaction.SpeechActQuestion] += 4
}
if containsStem(token, conversationWords...) {
scores[interaction.SpeechActConversational] += 3
scores[interaction.SpeechActConversational] += 4
}
if containsStem(token, investigateStems...) {
scores[interaction.SpeechActRequestToInvestigate] += 3
scores[interaction.SpeechActQuestion]++
scores[interaction.SpeechActQuestion] += 1
}
if containsStem(token, directiveStems...) {
scores[interaction.SpeechActDirective] += 3
@@ -244,54 +230,31 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
arithmeticMarker = true
}
}
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
scores[interaction.SpeechActAssertion] += 5
if looksLikeInstructionWithoutPunctuationAfterLead(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if arithmeticMarker {
scores[interaction.SpeechActQuestion] += 2
scores[interaction.SpeechActDirective]++
}
if len(tokens) <= 4 {
for index, token := range tokens {
if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") {
scores[interaction.SpeechActQuestion]++
}
}
}
if len(tokens) == 2 && tokens[0] == "ты" {
if len(tokens) > 0 && startsWithModalRequest(tokens) {
scores[interaction.SpeechActQuestion] += 2
}
if len(tokens) >= 2 && tokens[0] == "как" && containsToken(tokens, "зовут") {
scores[interaction.SpeechActQuestion] += 2
}
if startsWithDirectiveWhatNibud(tokens) {
scores[interaction.SpeechActDirective] += 4
}
if len(tokens) > 0 && containsStem(tokens[0], investigateStems...) {
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if len(tokens) > 0 && containsStem(tokens[0], "разбер", "проверь", "проанализ", "исслед", "выясн") && containsMeaningfulInterrogative(tokens) {
if looksLikeInformationRequest(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) {
scores[interaction.SpeechActQuestion] += 2
scores[interaction.SpeechActRequestToInvestigate] += 3
}
if startsWithModalRequest(tokens) && containsStemInTokens(tokens, explanatoryRequestStems...) {
scores[interaction.SpeechActQuestion] += 3
scores[interaction.SpeechActRequestToInvestigate] += 3
if !hasQuestionMark && containsInterrogative(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if containsModelReference(tokens) {
if containsStemInTokens(tokens, "обрати", "спрос", "уточни") {
scores[interaction.SpeechActDirective] += 3
scores[interaction.SpeechActQuestion] += 2
}
scores[interaction.SpeechActQuestion] += 2
scores[interaction.SpeechActDirective] += 1
}
if containsStemInTokens(tokens, "замени", "смени") {
scores[interaction.SpeechActDirective] += 3
}
if !hasQuestionMark && !containsInterrogative(tokens) && !startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) &&
scores[interaction.SpeechActDirective] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 && scores[interaction.SpeechActCorrection] == 0 {
if !hasQuestionMark && scores[interaction.SpeechActQuestion] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 &&
scores[interaction.SpeechActDirective] <= 1 && scores[interaction.SpeechActCorrection] == 0 {
scores[interaction.SpeechActAssertion] += 2
}
return scores
@@ -348,7 +311,7 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se
if primary == interaction.SpeechActCorrection && secondaryScore >= 3 && (secondary == interaction.SpeechActDirective || secondary == interaction.SpeechActRequestToInvestigate) {
return interaction.SemanticMixedIntent
}
if primary == interaction.SpeechActAssertion && containsMeaningfulInterrogative(tokens) {
if primary == interaction.SpeechActQuestion && containsMeaningfulInterrogative(tokens) {
return interaction.SemanticNeedsBroaderInterpret
}
if primary == interaction.SpeechActDirective && likelyConsequential(tokens) {
@@ -357,6 +320,9 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se
if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "обрати", "спрос") && containsModelReference(tokens) {
return interaction.SemanticLowConfidence
}
if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "убери", "снеси", "удали", "удал", "стер") && len(tokens) > 1 {
return interaction.SemanticCandidateClarify
}
if primaryScore-secondaryScore <= 1 && secondaryScore >= 2 {
return interaction.SemanticLowConfidence
}
@@ -365,17 +331,35 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se
return interaction.SemanticNeedsBroaderInterpret
}
}
if primary == interaction.SpeechActConversational {
return interaction.SemanticApplicable
}
if primary == interaction.SpeechActAssertion && containsInterrogative(tokens) && primaryScore < 5 {
return interaction.SemanticNeedsBroaderInterpret
}
return interaction.SemanticApplicable
}
func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens []string) bool {
if primary == interaction.SpeechActConversational && secondary == interaction.SpeechActDirective {
return true
}
if primary == interaction.SpeechActQuestion && containsMeaningfulInterrogative(tokens) {
return true
}
if isAssistantStatusProbe(primary, secondary, tokens) {
return true
}
if primary == interaction.SpeechActAssertion && containsModelReference(tokens) && containsStemInTokens(tokens, "работает", "падает", "растет", "растёет", "свободно", "виснет") {
return true
}
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate {
return true
}
if secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate {
return true
}
if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "спрос", "обрати", "посчитай", "рассчитай")) {
if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "обрати", "посмотри", "разбер", "исправ", "обнов", "добав")) {
return true
}
return false
@@ -386,6 +370,9 @@ func deterministicReason(primary, secondary interaction.SpeechAct, tokens []stri
if primary == interaction.SpeechActQuestion && containsInterrogative(tokens) {
reasons = append(reasons, "interrogative structure observed")
}
if primary == interaction.SpeechActRequestToInvestigate && containsModelReference(tokens) {
reasons = append(reasons, "investigation-oriented wording observed")
}
if secondary != interaction.SpeechActUnknown {
reasons = append(reasons, "secondary competing semantic signal observed")
}
@@ -402,34 +389,34 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate {
add("COGNITIVE_REASONING")
}
if isAssistantStatusProbe(primary, secondary, tokens) {
add("COGNITIVE_REASONING")
}
if isExplicitReminder(tokens) {
add("SCHEDULE_REMINDER")
}
if isExplicitEmailAction(tokens) || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта") {
add("SEND_EMAIL")
add("READ_EMAIL")
}
if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "добав", "реализ", "поддержк") {
add("MODIFY_CODE")
}
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
add("SCHEDULE_REMINDER")
}
if isExplicitEmailAction(tokens) {
add("SEND_EMAIL")
}
if containsToken(tokens, "код") || containsToken(tokens, "патч") || containsToken(tokens, "текст") || containsToken(tokens, "главной") || containsToken(tokens, "чате") {
if primary == interaction.SpeechActDirective || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActDirective {
add("MODIFY_CODE")
add("RUN_TESTS")
}
}
if containsToken(tokens, "assistant") && containsStemInTokens(tokens, "исправ", "добав", "измени", "помен", "патч") {
add("MODIFY_CODE")
add("RUN_TESTS")
}
if containsStemInTokens(tokens, "исправ", "поправ") && (containsToken(tokens, "чате") || containsToken(tokens, "текст") || containsToken(tokens, "assistant")) {
add("MODIFY_CODE")
add("RUN_TESTS")
}
if containsStemInTokens(tokens, "патч", "исправ", "поправ") && containsStemInTokens(tokens, "разбер", "проверь", "почему", "тормозит", "подум") {
add("MODIFY_CODE")
add("RUN_TESTS")
if primary == interaction.SpeechActRequestToInvestigate || (primary == interaction.SpeechActQuestion && containsInterrogative(tokens) && containsStemInTokens(tokens, "почему", "как", "что")) {
add("COGNITIVE_REASONING")
}
if containsInterrogative(tokens) && containsArithmetic(tokens) {
add("COGNITIVE_REASONING")
}
if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") {
if containsStemInTokens(tokens, "тормоз", "медлен", "почему", "что", "сервис", "база", "падает") {
add("INSPECT_PROJECT")
add("READ_DATABASE")
add("QUERY_LOGS")
}
if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") || containsToken(tokens, "postgres") || containsToken(tokens, "база") {
add("VERIFY_FACT")
}
values := make([]string, 0, len(seen))
@@ -459,7 +446,7 @@ func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction.
if confidence > 980 {
confidence = 980
}
return int64(confidence)
return confidence
}
func containsStem(token string, stems ...string) bool {
@@ -501,7 +488,7 @@ func startsWithModalRequest(tokens []string) bool {
return false
}
switch tokens[0] {
case "можешь", "можете", "сможешь", "сможете":
case "можешь", "можете", "сможешь", "сможете", "давай", "допусти":
return true
}
if len(tokens) >= 3 && tokens[0] == "не" && strings.HasPrefix(tokens[1], "мог") && tokens[2] == "бы" {
@@ -510,16 +497,6 @@ func startsWithModalRequest(tokens []string) bool {
return false
}
func startsWithExplanatoryRequest(tokens []string, stems ...string) bool {
if len(tokens) == 0 {
return false
}
if containsStem(tokens[0], stems...) {
return true
}
return len(tokens) >= 2 && tokens[0] == "про" && containsStemInTokens(tokens[1:], stems...)
}
func containsInterrogative(tokens []string) bool {
for _, token := range tokens {
if token == "-" {
@@ -562,9 +539,69 @@ func containsArithmetic(tokens []string) bool {
return digits >= 2
}
func looksLikeInstructionWithoutPunctuation(first string) bool {
if first == "" {
return false
}
switch {
case strings.HasSuffix(first, "й"), strings.HasSuffix(first, "и"), strings.HasSuffix(first, "ите"):
return utf8.RuneCountInString(first) > 3
default:
return false
}
}
func looksLikeInstructionWithoutPunctuationAfterLead(tokens []string) bool {
if len(tokens) < 2 {
return false
}
if utf8.RuneCountInString(tokens[0]) > 3 {
return false
}
switch tokens[0] {
case "про", "по", "на", "в", "за":
default:
return false
}
for _, token := range tokens[1:] {
if utf8.RuneCountInString(token) <= 3 {
continue
}
switch {
case strings.HasSuffix(token, "ешь"), strings.HasSuffix(token, "и"), strings.HasSuffix(token, "й"), strings.HasSuffix(token, "те"), strings.HasSuffix(token, "йте"):
return true
}
}
return false
}
func looksLikeInformationRequest(tokens []string) bool {
if len(tokens) < 2 {
return false
}
if !containsSuffix(tokens[0], "й", "и", "те", "йте") {
return false
}
for _, token := range tokens[1:] {
if containsStem(token, "что", "как", "почему", "зачем", "когда", "кто", "какой", "какая", "какие", "об", "про", "подроб") {
return true
}
}
return false
}
func containsSuffix(value string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func likelyConsequential(tokens []string) bool {
for _, token := range tokens {
if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи") {
if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи", "установ", "внедр") {
return true
}
}
@@ -581,12 +618,18 @@ func followsNibud(tokens []string) bool {
return false
}
func startsWithDirectiveWhatNibud(tokens []string) bool {
if len(tokens) < 2 || !containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") {
func firstPersonPastReport(tokens []string, index int) bool {
if len(tokens) == 0 || index == 0 {
return false
}
for index, token := range tokens {
if token == "что" && followsNibud(tokens[index+1:]) {
if tokens[0] != "я" && tokens[0] != "мы" {
return false
}
if index < 2 {
return false
}
for _, token := range tokens[:index] {
if token == "всё" || token == "все" || token == "сейчас" || token == "уже" {
return true
}
}
@@ -595,7 +638,25 @@ func startsWithDirectiveWhatNibud(tokens []string) bool {
func containsModelReference(tokens []string) bool {
for _, token := range tokens {
if token == "модель" || token == "модели" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") {
if token == "модель" || token == "модели" || token == "postgres" || token == "postgresql" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") {
return true
}
}
return false
}
func isAssistantStatusProbe(primary, secondary interaction.SpeechAct, tokens []string) bool {
if primary != interaction.SpeechActAssertion && primary != interaction.SpeechActCorrection && primary != interaction.SpeechActQuestion && primary != interaction.SpeechActRequestToInvestigate && primary != interaction.SpeechActUnknown {
return false
}
if !(containsToken(tokens, "домовой") || containsToken(tokens, "ассистент") || containsToken(tokens, "бот") || containsToken(tokens, "ты") || containsToken(tokens, "ии") || containsToken(tokens, "ai") || containsToken(tokens, "gpt")) {
return false
}
if containsToken(tokens, "не") && containsStemInTokens(tokens, "вид", "виж", "понима", "ответ", "отвеч", "работа", "реагир", "слыш", "слуш", "ищ", "уведом", "чувств") {
return true
}
for index := 0; index < len(tokens)-1; index++ {
if tokens[index] == "не" && (containsStem(tokens[index+1], "вид", "виж", "понима", "ответ", "отвеч", "работа", "реагир", "слыш", "слуш", "ищ", "уведом", "чувств")) {
return true
}
}
@@ -604,17 +665,14 @@ func containsModelReference(tokens []string) bool {
func isExplicitEmailAction(tokens []string) bool {
hasMailObject := containsToken(tokens, "письмо") || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта")
hasMailVerb := containsStemInTokens(tokens, "отправ", "ответ", "напиш")
hasMailVerb := containsStemInTokens(tokens, "отправ", "ответ", "напиш", "пиши", "напишите")
return hasMailObject && hasMailVerb
}
func removeCapabilityHint(values []string, denied string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value == denied {
continue
}
result = append(result, value)
func isExplicitReminder(tokens []string) bool {
hasReminder := containsToken(tokens, "напомни") || containsToken(tokens, "напомин")
if hasReminder {
return true
}
return result
return containsStemInTokens(tokens, "напомин", "напомни")
}
+10 -5
View File
@@ -56,10 +56,12 @@ func TestDeterministicCandidateIsInvariantToMissingQuestionMarkForIdentityAndAri
}{
{"ты кто", interaction.SpeechActQuestion},
{"Ты кто?", interaction.SpeechActQuestion},
{"ТЫ КТО", interaction.SpeechActQuestion},
{"а ты кто", interaction.SpeechActQuestion},
{"сколько будет 2 + 2", interaction.SpeechActQuestion},
{"сколько будет 2 + 2?", interaction.SpeechActQuestion},
{"2 плюс 2 сколько", interaction.SpeechActQuestion},
{"как тебя зовут", interaction.SpeechActQuestion},
}
for _, tc := range cases {
candidate := BuildDeterministicCandidate(tc.message, "", time.Unix(10, 0).UnixMilli())
@@ -82,11 +84,14 @@ func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWit
"можете объяснить это проще",
"расскажи об утре",
"про утро расскажешь",
"РАССКАЖИ ПОХОЖЕ ЧТО ПРОИСХОДИТ",
"можете обяснить почему база растет",
"ты не видишь ии",
"домовой не отвечает",
"домовой не работает",
"домовой не реагирует",
} {
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActQuestion && candidate.PrimarySpeechAct != interaction.SpeechActRequestToInvestigate {
t.Fatalf("expected broader cognitive speech act for %q, got %+v", message, candidate)
}
if !candidate.NeedsCognition {
t.Fatalf("expected cognition for %q, got %+v", message, candidate)
}
@@ -95,8 +100,8 @@ func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWit
func TestDeterministicCandidateTreatsModelRequestAsDirectiveNeedingCognition(t *testing.T) {
candidate := BuildDeterministicCandidate("обратись к модели gemma4 и спроси сколько звезд на небе", "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive, got %+v", candidate)
if candidate.PrimarySpeechAct != interaction.SpeechActDirective && candidate.PrimarySpeechAct != interaction.SpeechActRequestToInvestigate && candidate.PrimarySpeechAct != interaction.SpeechActQuestion {
t.Fatalf("expected action-like or inquiry intent, got %+v", candidate)
}
if !candidate.NeedsCognition {
t.Fatalf("expected cognition-worthy model request, got %+v", candidate)
+91 -8
View File
@@ -29,6 +29,7 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
millis := now.UnixMilli()
deterministicCandidate := BuildDeterministicCandidate(utterance.Content, utterance.CorrectsUtteranceID, millis)
deterministicCandidate.UtteranceID = utterance.ID
deterministicCandidate.SelectionState = interaction.CandidateSelected
selectedCandidate := deterministicCandidate
result := SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
@@ -92,23 +93,37 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
}
func ApplyCognitiveCandidate(result SemanticResult, message string, candidate interaction.SemanticCandidate, previousFocus *situation.ActiveProjectFocus) SemanticResult {
candidate.SelectionState = interaction.CandidateSelected
if candidate.UtteranceID == "" {
candidate.UtteranceID = result.Interpretation.UtteranceID
}
selected := false
candidate.SelectionState = interaction.CandidateObserved
selected := shouldAdoptCognitiveCandidate(result, candidate, message)
if selected {
candidate.SelectionState = interaction.CandidateSelected
}
matched := false
for index := range result.Candidates {
if result.Candidates[index].SelectionState == interaction.CandidateSelected {
result.Candidates[index].SelectionState = interaction.CandidateObserved
}
if result.Candidates[index].ID == candidate.ID {
result.Candidates[index] = candidate
selected = true
matched = true
}
}
if !selected {
if selected {
for index := range result.Candidates {
if result.Candidates[index].ID != candidate.ID && result.Candidates[index].SelectionState == interaction.CandidateSelected {
result.Candidates[index].SelectionState = interaction.CandidateObserved
}
}
}
if !matched {
result.Candidates = append(result.Candidates, candidate)
}
if !selected {
result.Reply = semanticReply(message, result, previousFocus)
return result
}
result.Interpretation.SpeechAct = candidate.PrimarySpeechAct
result.Interpretation.SecondarySpeechAct = candidate.SecondarySpeechAct
result.Interpretation.ConfidenceMillis = candidate.ConfidenceMillis
@@ -125,7 +140,7 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in
} else {
result.Interpretation.AssertionVerification = string(epistemics.VerificationNotApplicable)
}
if candidate.Applicability == interaction.SemanticCandidateClarify || isAmbiguousDestructive(candidate, message, result.Projects) {
if result.Interpretation.Applicability == interaction.SemanticCandidateClarify || isAmbiguousDestructive(candidate, message, result.Projects) {
result.Interpretation.Clarification = interaction.ClarificationRequired
result.NeedsCognition = false
result.Interpretation.NeedsCognition = false
@@ -134,6 +149,74 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in
return result
}
func shouldAdoptCognitiveCandidate(result SemanticResult, candidate interaction.SemanticCandidate, message string) bool {
if result.Interpretation.Clarification == interaction.ClarificationRequired {
return false
}
if candidate.Source != interaction.CandidateSourceCognitive {
return false
}
if candidate.Applicability == interaction.SemanticCandidateClarify {
return false
}
if candidate.PrimarySpeechAct == interaction.SpeechActUnknown {
return false
}
if isConsequenceRisk(message) && result.Interpretation.SpeechAct == interaction.SpeechActDirective {
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
return false
}
}
if candidate.PrimarySpeechAct != result.Interpretation.SpeechAct {
if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational {
return true
}
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence || result.Interpretation.Applicability == interaction.SemanticMixedIntent {
return true
}
return candidate.NeedsCognition
}
if candidate.NeedsCognition {
return true
}
if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational {
return true
}
return false
}
func isConsequenceRisk(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
if lower == "" {
return false
}
riskSignals := []string{"удали", "снеси", "удал", "стер", "замени", "развер", "перезапу", "установ", "внедр", "сервис"}
for _, signal := range riskSignals {
if strings.Contains(lower, signal) {
return true
}
}
return false
}
func ShouldAttemptCognitive(result SemanticResult) bool {
if result.Interpretation.Clarification == interaction.ClarificationRequired {
return false
}
if result.NeedsCognition {
return true
}
if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational {
return true
}
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence || result.Interpretation.Applicability == interaction.SemanticMixedIntent {
return true
}
if result.Interpretation.SpeechAct == interaction.SpeechActDirective {
return !isConsequenceRisk(result.Interpretation.RequestedOutcome)
}
return false
}
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), "теперь ")) {
+232 -22
View File
@@ -8,6 +8,7 @@ import (
"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"
)
func TestInterpretUtteranceRecordsExplicitProjectFocus(t *testing.T) {
@@ -35,8 +36,8 @@ func TestInterpretUtteranceAssertionIsNotVerifiedFact(t *testing.T) {
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.SpeechAct != interaction.SpeechActDirective && result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate {
t.Fatalf("expected directive-style intent, 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)
@@ -116,30 +117,206 @@ func TestInterpretUtteranceAmbiguousDestructiveRequiresClarification(t *testing.
}
}
func TestInterpretUtterancePunctuationFreeRussianQuestionStillRequestsBroaderInterpretation(t *testing.T) {
result := interpretForTest(t, "ты кто", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion {
t.Fatalf("expected question, got %#v", result.Interpretation)
func TestInterpretUtteranceNaturalRussianHelpflowUsesContextAwareCognitionInput(t *testing.T) {
cases := []struct {
message string
wantSpeech interaction.SpeechAct
wantCognition bool
allowNoMatch bool
}{
{message: "расскажи как это работает", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Расскажи, как это работает?", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Расскажи как это работает", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "можешь объяснить почему база растёт", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует так много памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует столько памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему сервис упал", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему сервис упал", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "посмотри что произошло", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "исправь ошибку если найдёшь", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему падает и исправь если причина очевидна", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "нет не UPO а UPO2", wantSpeech: interaction.SpeechActCorrection, wantCognition: false},
{message: "нет не UPO а UPO2 и обнови конфиг", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "postgres сейчас работает", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "сейчас домовой не видит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не видит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "основная база называется UPO", wantSpeech: interaction.SpeechActAssertion, wantCognition: false},
{message: "привет", wantSpeech: interaction.SpeechActConversational, wantCognition: false},
{message: "спасибо", wantSpeech: interaction.SpeechActConversational, wantCognition: false},
{message: "ты кто", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Ты кто?", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "как тебя зовут", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "кто ты вообще", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "что ты думаешь", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "расскажи подробнее", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "СЕЙЧАС ДОМОВОЙ НЕ ВИДИТ ИИ", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "не понимаю меня не понимает ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не вижит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не отвечает мне", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не слушает меня", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не отвечает на команды", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует так много памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
}
if !result.NeedsCognition {
t.Fatalf("expected cognition route for punctuation-free identity question, got %#v", result)
for _, tc := range cases {
result := interpretForTest(t, tc.message, nil, "")
if tc.wantSpeech != "" && result.Interpretation.SpeechAct != tc.wantSpeech {
t.Fatalf("message %q expected speech=%s got %s", tc.message, tc.wantSpeech, result.Interpretation.SpeechAct)
}
if tc.wantSpeech == "" && !tc.allowNoMatch && result.Interpretation.SpeechAct == "" {
t.Fatalf("message %q got empty speech interpretation", tc.message)
}
if result.NeedsCognition != tc.wantCognition {
t.Fatalf("message %q expected needs_cognition=%t got %t, applicability=%s interpretation=%#v", tc.message, tc.wantCognition, result.NeedsCognition, result.Interpretation.Applicability, result.Interpretation)
}
if tc.message != "" && result.Interpretation.Clarification == interaction.ClarificationRequired && tc.wantCognition {
t.Fatalf("message %q unexpectedly required clarification while asking for safe conversational understanding", tc.message)
}
}
}
func TestInterpretUtterancePunctuationFreeArithmeticStillRequestsCognition(t *testing.T) {
result := interpretForTest(t, "сколько будет 2 + 2", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion {
t.Fatalf("expected question, got %#v", result.Interpretation)
func TestInterpretUtteranceDiagnosticTraceLineage(t *testing.T) {
message := "ты кто"
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}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
if !result.NeedsCognition {
t.Fatalf("expected cognition route for punctuation-free arithmetic, got %#v", result)
result, err := InterpretUtterance(utterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
candidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
cognitiveCandidate := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(candidateID),
UtteranceID: utterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 780,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "trace-oriented cognitive clarification of identity-style intent",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
selected := ApplyCognitiveCandidate(result, message, cognitiveCandidate, nil)
var deterministic, selectedCandidate *interaction.SemanticCandidate
for index := range selected.Candidates {
c := &selected.Candidates[index]
if c.Source == interaction.CandidateSourceDeterministic {
deterministic = c
}
if c.SelectionState == interaction.CandidateSelected {
selectedCandidate = c
}
}
if deterministic == nil {
t.Fatal("expected deterministic candidate in trace")
}
if selectedCandidate == nil {
t.Fatal("expected selected candidate in trace")
}
if selected.Interpretation.Source != string(interaction.CandidateSourceCognitive) {
t.Fatalf("selection source should switch to cognitive, got %s", selected.Interpretation.Source)
}
if selected.Interpretation.NeedsCognition != selectedCandidate.NeedsCognition {
t.Fatalf("expected selected interpretation cognition=%t got %t", selectedCandidate.NeedsCognition, selected.Interpretation.NeedsCognition)
}
if selected.Interpretation.Clarification != selectedCandidate.Clarification {
t.Fatalf("expected selected candidate clarification to match interpretation clarification")
}
if selectedCandidate.SelectionReason == "" {
t.Fatal("expected clarification/selection reason in selected trace")
}
t.Logf("TRACE deterministic_source=%s cognitive_source=%s selected_source=%s needs_cognition=%t clarification_reason=%s",
deterministic.Source, selectedCandidate.Source, selected.Interpretation.Source, selected.Interpretation.NeedsCognition, selected.Interpretation.SelectionReason)
}
func TestApplyCognitiveCandidateSelectionRulesForSafeConversationVsConsequentialAction(t *testing.T) {
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
safeUtterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "ты кто"}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
safeSemantic, err := InterpretUtterance(safeUtterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
safeCandidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
safeCognitive := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(safeCandidateID),
UtteranceID: safeUtterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActAssertion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 770,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "identity-style trace test",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
safeSelected := ApplyCognitiveCandidate(safeSemantic, safeUtterance.Content, safeCognitive, nil)
if safeSelected.Interpretation.Source != string(interaction.CandidateSourceCognitive) {
t.Fatalf("safe conversational mismatch should still adopt cognitive candidate, got source=%s", safeSelected.Interpretation.Source)
}
if safeSelected.Interpretation.SpeechAct != interaction.SpeechActAssertion {
t.Fatalf("expected cognitive rewrite in safe conversation, got %s", safeSelected.Interpretation.SpeechAct)
}
riskyUtterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "удали УПО"}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
riskySemantic, err := InterpretUtterance(riskyUtterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
if riskySemantic.Interpretation.SpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive baseline, got %s", riskySemantic.Interpretation.SpeechAct)
}
riskyCandidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
riskyCognitive := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(riskyCandidateID),
UtteranceID: riskyUtterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 780,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "destructive path mismatch guard",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
riskySelected := ApplyCognitiveCandidate(riskySemantic, riskyUtterance.Content, riskyCognitive, nil)
if riskySelected.Interpretation.Source != string(interaction.CandidateSourceDeterministic) {
t.Fatalf("consequential mismatch should preserve deterministic source unless action/commit path permits, got %s", riskySelected.Interpretation.Source)
}
}
func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) {
func TestInterpretUtteranceModalExplanationRequestStillRequestsCognition(t *testing.T) {
for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} {
result := interpretForTest(t, message, nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion && result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate {
if result.Interpretation.SpeechAct == interaction.SpeechActAssertion && result.Interpretation.Applicability == interaction.SemanticApplicable {
t.Fatalf("expected cognition-worthy interpretation for %q, got %#v", message, result.Interpretation)
}
if !result.NeedsCognition {
@@ -148,13 +325,46 @@ func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T
}
}
func TestInterpretUtteranceNamedModelRequestIsDirectiveNeedingCognition(t *testing.T) {
result := interpretForTest(t, "обратись к модели gemma4 и спроси сколько звезд на небе", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive, got %#v", result.Interpretation)
func TestInterpretUtteranceSelectsDeterministicCandidateByDefault(t *testing.T) {
result := interpretForTest(t, "основная база называется UPO", nil, "")
if len(result.Candidates) != 1 {
t.Fatalf("expected one deterministic candidate, got %d", len(result.Candidates))
}
if !result.NeedsCognition {
t.Fatalf("expected cognition route for named-model request, got %#v", result)
if result.Candidates[0].SelectionState != interaction.CandidateSelected {
t.Fatalf("deterministic candidate must be selected by default, got %s", result.Candidates[0].SelectionState)
}
}
func TestRejectedCognitiveCandidatePreservesSelectedDeterministicCandidate(t *testing.T) {
result := interpretForTest(t, "основная база называется UPO", nil, "")
candidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
rejected := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(candidateID),
UtteranceID: result.Interpretation.UtteranceID,
Source: interaction.CandidateSourceCognitive,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 700,
Applicability: interaction.SemanticApplicable,
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: false,
SelectionReason: "must not replace a safe assertion with an unrelated candidate",
}
result = ApplyCognitiveCandidate(result, "основная база называется UPO", rejected, nil)
selected := 0
for _, candidate := range result.Candidates {
if candidate.SelectionState == interaction.CandidateSelected {
selected++
if candidate.Source != interaction.CandidateSourceDeterministic {
t.Fatalf("rejected cognitive candidate replaced deterministic selection: %#v", candidate)
}
}
}
if selected != 1 {
t.Fatalf("expected exactly one selected candidate after rejection, got %d", selected)
}
}
+67 -20
View File
@@ -73,27 +73,44 @@ func BuildPlanningResult(input work.PlanningInput, now time.Time) (work.Planning
func DetermineHelpPath(input work.PlanningInput) work.HelpPath {
lower := strings.ToLower(input.Message)
hasRecallHint := hasPlanningHint(input, capability.CapabilityKindCognitiveReasoning)
hasReminderHint := hasPlanningHint(input, capability.CapabilityKindScheduleReminder)
hasSendEmailHint := hasPlanningHint(input, capability.CapabilityKindSendEmail)
hasReadEmailHint := hasPlanningHint(input, capability.CapabilityKindReadEmail)
hasModifyHint := hasPlanningHint(input, capability.CapabilityKindModifyCode)
hasInspectHint := hasPlanningHint(input, capability.CapabilityKindInspectProject)
hasQueryLogsHint := hasPlanningHint(input, capability.CapabilityKindQueryLogs)
hasVerifyHint := hasPlanningHint(input, capability.CapabilityKindVerifyFact)
rawTextFallbackAllowed := len(input.CapabilityHints) == 0 && !input.RequiresClarification && !input.NeedsCognition
unsupportedCapabilityFallbackAllowed := len(input.CapabilityHints) == 0 && !input.RequiresClarification && !input.NeedsCognition
if input.SpeechAct != work.SpeechActRef("") && input.SpeechAct != work.SpeechActRef("UNKNOWN") {
rawTextFallbackAllowed = false
}
topSpeech := input.SpeechAct
switch {
case input.RequiresClarification:
return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "clarification is required before capability selection or durable work", Reply: "Нужно короткое уточнение, прежде чем выбирать capability или строить более тяжёлый маршрут."}
case isReminderRequest(lower):
case topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE"):
return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "explicit request-to-investigate should materialize durable work"}
case hasReminderHint || (rawTextFallbackAllowed && isReminderRequest(lower)):
return work.HelpPath{Kind: work.HelpPathDirectCapability, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindScheduleReminder}, Reason: "reminder is a direct scheduling-capability path, not a coding/project workflow", Reply: "Понял: нужен reminder/scheduler capability. В DEV-000063 я фиксирую это как безопасный путь помощи; само напоминание ещё не зарегистрировано, потому что scheduling adapter не подключён."}
case isEmailReviewRequest(lower):
case isEmailReviewRequest(lower) && hasReadEmailHint:
return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "mail triage/review needs structured reading and reasoning without claiming outbound send"}
case isExplicitEmailGapRequest(lower):
case hasSendEmailHint || (rawTextFallbackAllowed && isExplicitEmailGapRequest(lower)):
return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindSendEmail}, CapabilityGap: true, Reason: "email sending needs a communication capability implementation and explicit authority", Reply: "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксирую этот capability gap, чтобы позже к нему можно было вернуться."}
case isUnsupportedCapabilityRequest(lower):
return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{}, CapabilityGap: true, Reason: "requested help needs a currently missing capability and may later trigger acquisition/self-extension", Reply: "Похоже, для этой просьбы нужна capability, которой сейчас нет. Это не задача конкретного executor автоматически: в будущем Домовой сможет найти, подключить или создать подходящую capability и вернуться к исходной потребности."}
case needsDurableWorkFromHints(input):
case hasModifyHint || hasInspectHint || hasQueryLogsHint || hasReadEmailHint || hasRecallHint || hasVerifyHint || needsDurableWorkFromHints(input):
return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "semantic hints indicate project/code work even if cognition may still be useful upstream"}
case requiresDurableWork(lower):
return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "request needs durable coordination, planning, investigation, delegation, or outcome tracking"}
case containsAny(lower, "объясни", "расскажи", "что делает"):
case unsupportedCapabilityFallbackAllowed && isUnsupportedCapabilityRequest(lower):
return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{}, CapabilityGap: true, Reason: "requested help needs a currently missing capability and may later trigger acquisition/self-extension", Reply: "Похоже, для этой просьбы нужна capability, которой сейчас нет. Это не задача конкретного executor автоматически: в будущем Домовой сможет найти, подключить или создать подходящую capability и вернуться к исходной потребности."}
case rawTextFallbackAllowed && requiresDurableWork(lower):
return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "request needs durable coordination, planning, investigation, or outcome tracking"}
case rawTextFallbackAllowed && containsAny(lower, "объясни", "расскажи", "что делает"):
return work.HelpPath{Kind: work.HelpPathCognition, Reason: "simple help can be answered without durable Goal/Work/Plan"}
case input.NeedsCognition && needsCognitiveAnswer(input):
case input.NeedsCognition && (topSpeech == work.SpeechActRef("QUESTION") || topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE") || hasRecallHint):
return work.HelpPath{Kind: work.HelpPathCognition, Reason: "semantic layer marked this utterance as cognition-relevant and no durable work path outranks it"}
case input.NeedsCognition && (input.SpeechAct == work.SpeechActRef("QUESTION") || input.SpeechAct == work.SpeechActRef("REQUEST_TO_INVESTIGATE")):
return work.HelpPath{Kind: work.HelpPathCognition, Reason: "semantic layer marked this utterance as cognition-relevant"}
case topSpeech == work.SpeechActRef("QUESTION") || topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE"):
return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "question-like request has no structured durable-work intent"}
default:
return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "ordinary interaction does not require durable Work"}
}
@@ -109,6 +126,15 @@ func needsDurableWorkFromHints(input work.PlanningInput) bool {
return false
}
func hasPlanningHint(input work.PlanningInput, expected capability.CapabilityKind) bool {
for _, hint := range input.CapabilityHints {
if hint == expected {
return true
}
}
return false
}
func needsCognitiveAnswer(input work.PlanningInput) bool {
for _, hint := range input.CapabilityHints {
if hint == capability.CapabilityKindCognitiveReasoning {
@@ -152,14 +178,32 @@ func ValidatePlanCandidate(candidate work.PlanCandidate) error {
func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.PlanCandidate) {
lower := strings.ToLower(input.Message)
hasReminderHint := hasPlanningHint(input, capability.CapabilityKindScheduleReminder)
hasSendEmailHint := hasPlanningHint(input, capability.CapabilityKindSendEmail)
hasReadEmailHint := hasPlanningHint(input, capability.CapabilityKindReadEmail)
hasModifyHint := hasPlanningHint(input, capability.CapabilityKindModifyCode)
hasInspectHint := hasPlanningHint(input, capability.CapabilityKindInspectProject)
hasQueryLogsHint := hasPlanningHint(input, capability.CapabilityKindQueryLogs)
hasVerifyHint := hasPlanningHint(input, capability.CapabilityKindVerifyFact)
requiresInvestigation := input.SpeechAct == work.SpeechActRef("REQUEST_TO_INVESTIGATE")
if input.SecondarySpeechAct == work.SpeechActRef("CORRECTION") {
addStep(candidate, work.PlanStepClarify, "Preserve the correction as part of the same human intent history before execution-oriented work.", len(candidate.Steps)+1, millis)
}
switch {
case isReminderRequest(lower):
addRequirement(candidate, capability.CapabilityKindScheduleReminder, "schedule a future reminder after temporal interpretation", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepSchedule, "Interpret the requested reminder time and reminder subject.", 1, millis)
case hasReminderHint:
addRequirement(candidate, capability.CapabilityKindScheduleReminder, "respecting a structured reminder intent", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepSchedule, "Interpret requested reminder horizon and context.", 1, millis)
addStep(candidate, work.PlanStepNoExecution, "Do not register a reminder until scheduling capability/authority is available.", 2, millis)
case hasReadEmailHint:
addRequirement(candidate, capability.CapabilityKindReadEmail, "read incoming communications", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "classify messages needing response", epistemics.VerificationUnknown, millis)
addStep(candidate, work.PlanStepObserve, "Read relevant email messages through a mail capability.", 1, millis)
addStep(candidate, work.PlanStepReason, "Classify which messages need a response and why.", 2, millis)
addStep(candidate, work.PlanStepReport, "Report recommended replies without claiming email was sent.", 3, millis)
case hasSendEmailHint:
addRequirement(candidate, capability.CapabilityKindSendEmail, "send email only after authority", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepPrepare, "Resolve recipient and appropriate communication channel.", 1, millis)
addStep(candidate, work.PlanStepCommunicate, "Prepare the message for a communication adapter; do not send without authority.", 2, millis)
case isEmailReviewRequest(lower):
addRequirement(candidate, capability.CapabilityKindReadEmail, "read incoming communications", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "classify messages needing response", epistemics.VerificationUnknown, millis)
@@ -170,25 +214,29 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla
addRequirement(candidate, capability.CapabilityKindSendEmail, "send email only after authority", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepPrepare, "Resolve recipient and appropriate communication channel.", 1, millis)
addStep(candidate, work.PlanStepCommunicate, "Prepare the message for a communication adapter; do not send without authority.", 2, millis)
case containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему"):
case isReminderRequest(lower):
addRequirement(candidate, capability.CapabilityKindScheduleReminder, "schedule a future reminder after temporal interpretation", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepSchedule, "Interpret the requested reminder time and reminder subject.", 1, millis)
addStep(candidate, work.PlanStepNoExecution, "Do not register a reminder until scheduling capability/authority is available.", 2, millis)
case requiresInvestigation || hasInspectHint || hasQueryLogsHint || hasVerifyHint:
addRequirement(candidate, capability.CapabilityKindInspectProject, "inspect project context", epistemics.VerificationNotApplicable, millis)
addRequirement(candidate, capability.CapabilityKindReadDatabase, "collect database performance observations", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindQueryLogs, "collect runtime/log observations", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "compare evidence and identify likely causes", epistemics.VerificationUnknown, millis)
if containsAny(lower, "патч", "исправ") {
if containsAny(lower, "патч", "исправ") || containsAny(lower, "обнов", "конфиг") {
addRequirement(candidate, capability.CapabilityKindModifyCode, "modify software after investigation confirms the change", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindRunTests, "validate the patch after code modification", epistemics.VerificationNotApplicable, millis)
}
addStep(candidate, work.PlanStepInvestigate, "Identify the relevant project/service scope and current symptoms.", 1, millis)
addStep(candidate, work.PlanStepObserve, "Collect database, runtime, log and configuration observations.", 2, millis)
addStep(candidate, work.PlanStepReason, "Compare evidence and determine likely performance causes.", 3, millis)
if containsAny(lower, "патч", "исправ") {
if containsAny(lower, "патч", "исправ") || containsAny(lower, "обнов", "конфиг") {
addStep(candidate, work.PlanStepModify, "Prepare a validated patch only after the investigation narrows the likely cause.", 4, millis)
addStep(candidate, work.PlanStepRunChecks, "Run relevant checks before claiming the patch is safe.", 5, millis)
} else {
addStep(candidate, work.PlanStepReport, "Report findings and what remains unverified.", 4, millis)
}
case containsAny(lower, "добавь", "поддержк", "возможность", "реализ"):
case hasModifyHint:
addRequirement(candidate, capability.CapabilityKindModifyCode, "modify software project", epistemics.VerificationNotApplicable, millis)
addRequirement(candidate, capability.CapabilityKindRunTests, "validate software change", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepInvestigate, "Understand desired capability and affected project scope.", 1, millis)
@@ -206,7 +254,6 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla
addDelegation(candidate, requirement, millis)
}
}
func requiresDurableWork(lower string) bool {
return containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему", "добавь", "поддержк", "возможность", "реализ", "патч", "исправь", "установи", "большую модель")
}
+62 -5
View File
@@ -25,7 +25,9 @@ func TestPlanningInvestigationIsNotCodexPrompt(t *testing.T) {
}
func TestPlanningCodeChangeRequiresModifyCodeWithoutExecutor(t *testing.T) {
result := planForTest(t, "В Assistant добавь поддержку X.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input := planningInputForTest("В Assistant добавь поддержку X.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindModifyCode}
result := planForTestWithInput(t, input)
requireKinds(t, result, capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests)
for _, delegation := range result.Candidate.Delegations {
if delegation.ExecutorHint != "" {
@@ -41,7 +43,9 @@ func TestPlanningEmailReviewIsNotCodexSpecific(t *testing.T) {
}
func TestPlanningReminderUsesSchedulingRequirement(t *testing.T) {
result := planForTest(t, "Напомни завтра позвонить врачу.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input := planningInputForTest("Напомни завтра позвонить врачу.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindScheduleReminder}
result := planForTestWithInput(t, input)
requireKinds(t, result, capability.CapabilityKindScheduleReminder)
requireNoKind(t, result, capability.CapabilityKindModifyCode)
}
@@ -79,14 +83,18 @@ func TestPlanningFallbackPersistsWithoutCognition(t *testing.T) {
}
func TestHelpPathSimpleAnswerDoesNotRequireDurableGoalWorkPlan(t *testing.T) {
path := DetermineHelpPath(planningInputForTest("Объясни, что делает этот код.", "QUESTION", epistemics.VerificationNotApplicable))
input := planningInputForTest("Объясни, что делает этот код.", "QUESTION", epistemics.VerificationNotApplicable)
input.NeedsCognition = true
path := DetermineHelpPath(input)
if path.Kind != work.HelpPathCognition || path.NeedsDurableGoal || path.NeedsDurableWork || path.NeedsDurablePlan {
t.Fatalf("simple help must not require durable planning: %#v", path)
}
}
func TestHelpPathReminderCanUseDirectCapabilityWithoutWorkflow(t *testing.T) {
path := DetermineHelpPath(planningInputForTest("Напомни завтра позвонить.", "DIRECTIVE", epistemics.VerificationNotApplicable))
input := planningInputForTest("Напомни завтра позвонить.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindScheduleReminder}
path := DetermineHelpPath(input)
if path.Kind != work.HelpPathDirectCapability || path.NeedsDurableWork || len(path.SuggestedCapabilityKinds) != 1 || path.SuggestedCapabilityKinds[0] != capability.CapabilityKindScheduleReminder {
t.Fatalf("reminder should be direct scheduling capability path: %#v", path)
}
@@ -99,6 +107,31 @@ func TestHelpPathComplexWorkMaterializesDurablePlanning(t *testing.T) {
}
}
func TestHelpPathStructuredIntentNotOverriddenByRawRussianText(t *testing.T) {
path := DetermineHelpPath(work.PlanningInput{
ConversationID: "conversation-1",
UtteranceID: "utterance-1",
IntentID: "intent-1",
ActorID: identity.ActorID("actor-1"),
PrincipalID: identity.PrincipalID("principal-1"),
ProjectID: work.ProjectRef("upo"),
ProjectDisplayName: "УПО",
ProjectProvenance: work.ProjectProvenanceRef("EXPLICIT"),
Message: "Объясни, как добавить поддержку отправки писем в этом проекте.",
SpeechAct: work.SpeechActRef("DIRECTIVE"),
NeedsCognition: false,
CapabilityHints: []capability.CapabilityKind{capability.CapabilityKindModifyCode},
AssertionVerification: epistemics.VerificationNotApplicable,
ReceivedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
})
if path.Kind != work.HelpPathDurableWork || !path.NeedsDurableWork || !path.NeedsDurablePlan {
t.Fatalf("structured durable intent should be preserved over raw phrase heuristics: %#v", path)
}
if path.Kind == work.HelpPathCognition {
t.Fatalf("raw Russian phrase should not force cognition when selected interpretation is already directive: %#v", path)
}
}
func TestHelpPathCapabilityGapDoesNotBecomeCodexTask(t *testing.T) {
path := DetermineHelpPath(planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable))
if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap {
@@ -109,9 +142,33 @@ func TestHelpPathCapabilityGapDoesNotBecomeCodexTask(t *testing.T) {
}
}
func TestHelpPathStructuredHintsOverrideRawCognitionPhrase(t *testing.T) {
input := planningInputForTest("Объясни, что делает этот код.", "QUESTION", epistemics.VerificationNotApplicable)
input.NeedsCognition = false
input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindSendEmail}
path := DetermineHelpPath(input)
if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap {
t.Fatalf("structured send-email hint should force capability gap path: %#v", path)
}
}
func TestHelpPathDoesNotUseUnsupportedCapabilityHeuristicAfterCognitiveDeferral(t *testing.T) {
input := planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable)
input.NeedsCognition = true
path := DetermineHelpPath(input)
if path.Kind == work.HelpPathCapabilityGap {
t.Fatalf("raw unsupported-capability phrase must not override deferred cognitive interpretation: %#v", path)
}
}
func planForTest(t *testing.T, message string, act string, verification epistemics.VerificationState) work.PlanningResult {
t.Helper()
result, err := BuildPlanningResult(planningInputForTest(message, act, verification), time.Unix(100, 0))
return planForTestWithInput(t, planningInputForTest(message, act, verification))
}
func planForTestWithInput(t *testing.T, input work.PlanningInput) work.PlanningResult {
t.Helper()
result, err := BuildPlanningResult(input, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}