diff --git a/cmd/cognitive-worker/interpretation.go b/cmd/cognitive-worker/interpretation.go index 4c01924..c3d814c 100644 --- a/cmd/cognitive-worker/interpretation.go +++ b/cmd/cognitive-worker/interpretation.go @@ -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) diff --git a/cmd/cognitive-worker/interpretation_live_trace_test.go b/cmd/cognitive-worker/interpretation_live_trace_test.go index 8b89c76..bb0bc9b 100644 --- a/cmd/cognitive-worker/interpretation_live_trace_test.go +++ b/cmd/cognitive-worker/interpretation_live_trace_test.go @@ -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 +} diff --git a/cmd/cognitive-worker/interpretation_test.go b/cmd/cognitive-worker/interpretation_test.go new file mode 100644 index 0000000..ab36ba8 --- /dev/null +++ b/cmd/cognitive-worker/interpretation_test.go @@ -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") + } +} diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index 9661eb4..ecdd3db 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -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 -} +} \ No newline at end of file diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index cce6d9e..75e2483 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -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 -} +} \ No newline at end of file diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 1d90693..e1f46e7 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -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(), "Спросить Домового") || diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 70c6dd1..dede0cc 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -190,7 +190,7 @@ async function sendChatMessage(text, options = {}) { activateTab("today-panel"); } } catch { - appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime."); + appendMessage("assistant", "Не могу отправить сообщение. Попробуйте ещё раз."); } finally { sendButton.disabled = false; messageInput.focus(); diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 8dbd237..008a05d 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -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; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 9ccec82..b201a79 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -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 = "Проверить исход"; diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 86e9619..942183e 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -6,9 +6,10 @@ Домовой — консоль создателя - + +
@@ -44,6 +45,12 @@
система доступна +
@@ -56,18 +63,45 @@

Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.

- - - - - - - - - - - +
+ ТЕКУЩИЙ КОНТЕКСТ + не выбран +
+
+ + + Сохраняются только в этом браузере. +
+
+ +
+
+
+ +
+
+

Для «Состояния» нужны только эти два значения. Задача, ожидание и возможность находятся ниже в расширенных полях.

+
+ Расширенные идентификаторы +
+ + + + + + +
+
+
+
ACTOR — @@ -77,7 +111,7 @@
-
+

операционная картина

Состояние сейчас

@@ -92,6 +126,20 @@
ВОЗМОЖНОСТЬожидает
+
+
+

Проверяемая система

Диагностика кабинета

+
Проверка ещё не запускалась
+
+
+ +

Здесь видны только ответы текущей авторизованной сессии. Проверка не меняет данные и помогает отличить отсутствие контекста от недоступности сервиса.

+
+
+

Нажмите «Проверить всё», чтобы получить актуальный снимок.

+
+
+

COGNITIVE / ADVISORY V1

@@ -120,7 +168,7 @@
-
+

Continuity / ближайшие

Реестр ожиданий

0 ЗАПИСЕЙ @@ -167,6 +215,19 @@
+
+
+

Локальные предпочтения

Настройки рабочего кабинета

+ Сохраняются в этом браузере +
+
+ + + +
+
+
+
01

Situation

Активный фокус

НЕ ЗАГРУЖЕН
@@ -188,6 +249,7 @@
Ревизия
—

Укажите задачу для проверки относительно активного фокуса.

+
@@ -205,6 +267,7 @@
Ревизия
—

Укажите идентификатор ProspectiveItem для проверки владельца и состояния.

+

Commit / контролируемый переход

@@ -240,6 +303,7 @@
Ревизия
—
Наблюдалось
—
+
@@ -286,6 +350,6 @@ - + diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index c90aef1..794be2d 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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. diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 3052249..2a3d773 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -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 } diff --git a/dev/tasks/DEV-000073-production-compose-fail-closed.json b/dev/tasks/DEV-000073-production-compose-fail-closed.json new file mode 100644 index 0000000..23a69bf --- /dev/null +++ b/dev/tasks/DEV-000073-production-compose-fail-closed.json @@ -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" + ] +} diff --git a/dev/tasks/DEV-000074-creator-cabinet-operations.json b/dev/tasks/DEV-000074-creator-cabinet-operations.json new file mode 100644 index 0000000..d4db07e --- /dev/null +++ b/dev/tasks/DEV-000074-creator-cabinet-operations.json @@ -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"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 5a50ab6..356b625 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -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` diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index e0868ea..5fee581 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -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, "напомин", "напомни") } diff --git a/modules/interaction/domain/chat_test.go b/modules/interaction/domain/chat_test.go index b1b5253..255ee32 100644 --- a/modules/interaction/domain/chat_test.go +++ b/modules/interaction/domain/chat_test.go @@ -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) diff --git a/modules/interaction/domain/semantics.go b/modules/interaction/domain/semantics.go index 37cf4c7..0531848 100644 --- a/modules/interaction/domain/semantics.go +++ b/modules/interaction/domain/semantics.go @@ -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), "теперь ")) { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index ad8c2f1..810ed62 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -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) } } diff --git a/modules/work/domain/planning.go b/modules/work/domain/planning.go index 03dee3b..58719bd 100644 --- a/modules/work/domain/planning.go +++ b/modules/work/domain/planning.go @@ -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, "тормозит", "медлен", "производительн", "разберись", "почему", "добавь", "поддержк", "возможность", "реализ", "патч", "исправь", "установи", "большую модель") } diff --git a/modules/work/domain/planning_test.go b/modules/work/domain/planning_test.go index 24e30f7..3370b18 100644 --- a/modules/work/domain/planning_test.go +++ b/modules/work/domain/planning_test.go @@ -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) }