返回 DeepSeek-Reasonix
goal_budget_intent.go
根目录 / internal / control / goal_budget_intent.go
1 // Goal budget helpers classify natural-language task text (English and
2 // Chinese) into delivery-intent categories. The delivery evidence gates and
3 // Goal budget selection consume it as a heuristic; it never gates permissions
4 // or whether writes are allowed.
5 package control
6
7 import (
8 "strings"
9 "unicode/utf8"
10 )
11
12 // Intent is the delivery expectation a task text implies.
13 type Intent uint8
14
15 const (
16 // Conversation is chat with no host-observable work expected.
17 Conversation Intent = iota
18 // Advisory asks for explanation or advice rather than observable work.
19 Advisory
20 // ObservableRead expects host-observable read-only work (inspect, review).
21 ObservableRead
22 // Mutation expects workspace changes.
23 Mutation
24 // PersistentAction expects durable state kept across sessions.
25 PersistentAction
26 )
27
28 // Classify maps a task text to the delivery intent it implies.
29 func Classify(input string) Intent {
30 switch {
31 case deliveryTaskHasMutationIntent(input):
32 return Mutation
33 case NeedsPersistentAction(input):
34 return PersistentAction
35 case deliveryTaskIsConversationOnly(input):
36 return Conversation
37 case !heuristicInputIsTask(input):
38 return Conversation
39 case deliveryTaskIsAdvisory(input):
40 return Advisory
41 default:
42 return ObservableRead
43 }
44 }
45
46 // NeedsEvidence reports whether the intent expects host-observable work.
47 func (i Intent) NeedsEvidence() bool {
48 return i == ObservableRead || i == Mutation || i == PersistentAction
49 }
50
51 // NeedsEvidence reports whether a task text expects host-observable work.
52 func NeedsEvidence(input string) bool {
53 return Classify(input).NeedsEvidence()
54 }
55
56 var deliveryMutationNeedles = []string{
57 "fix", "repair", "resolve", "create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
58 "implement", "refactor", "apply", "install", "publish", "commit", "push", "continue work",
59 "modify", "patch", "replace", "move", "configure", "upgrade", "downgrade", "bump", "enable", "disable", "merge",
60 "make changes", "make a change", "make the changes", "make the requested changes", "make the necessary changes", "make these changes", "make those changes", "make code changes",
61 "修复", "解决", "创建", "新建", "添加", "编写", "编辑", "修改", "更新", "删除", "移除", "重命名", "实现", "重构",
62 "实施", "落地", "安装", "发布", "提交", "继续处理", "调整", "替换", "移动", "升级", "降级", "启用", "禁用", "合并", "改动", "打补丁",
63 }
64
65 var deliveryAdvisoryPhrases = []string{
66 "what's wrong", "what is wrong", "why", "what should i do", "what can i do", "how should i", "how do i", "how can i",
67 "can you explain", "could you explain", "give me advice", "any advice", "help me understand",
68 "为什么", "怎么回事", "怎么办", "怎么", "怎样", "如何", "是什么问题", "什么原因", "的原因", "给我建议", "有什么建议",
69 }
70
71 func NeedsMutation(input string) bool {
72 intent := Classify(input)
73 return intent == Mutation || intent == PersistentAction
74 }
75
76 func deliveryTaskHasMutationIntent(input string) bool {
77 affirmative, _ := deliveryTaskMutationIntent(input)
78 return affirmative
79 }
80
81 func NeedsPersistentAction(input string) bool {
82 normalized := strings.ToLower(strings.TrimSpace(input))
83 if normalized == "" {
84 return false
85 }
86 actionNeedles := []string{
87 "remember", "save", "store", "keep this", "keep that",
88 "记住", "记下来", "保存", "存下来", "记录下来",
89 }
90 durableNeedles := []string{
91 "permanently", "durable", "long-term", "long term", "across sessions", "future sessions", "every session", "after restart", "after restarting",
92 "永久", "长期", "持久", "跨会话", "以后每次", "未来会话", "重启后", "下次启动",
93 }
94 for _, clause := range deliveryTaskClauses(normalized) {
95 action := false
96 for _, needle := range actionNeedles {
97 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
98 action = action || affirmative
99 }
100 durable := false
101 for _, needle := range durableNeedles {
102 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
103 durable = durable || affirmative
104 }
105 if action && durable && !deliveryTaskClauseIsAdvisory(clause) {
106 return true
107 }
108 }
109 return false
110 }
111
112 func deliveryTaskIsConversationOnly(input string) bool {
113 normalized := strings.ToLower(strings.TrimSpace(input))
114 if normalized == "" || deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
115 return false
116 }
117 localCue := containsAnySubstring(normalized, []string{
118 "next turn", "next message", "later in this chat", "this conversation", "when i ask again", "when i ask next",
119 "下一轮", "下轮", "下一条消息", "稍后再问", "待会再问", "这个对话", "本次对话", "本轮会话",
120 })
121 conversationAction := containsAnySubstring(normalized, []string{
122 "remember", "keep in mind", "keep this", "keep that", "answer", "respond", "reply",
123 "记住", "记一下", "回答", "回复", "再告诉我",
124 })
125 return localCue && conversationAction
126 }
127
128 func deliveryTaskMutationIntent(input string) (affirmative, negated bool) {
129 normalized := strings.ToLower(strings.TrimSpace(input))
130 for _, clause := range deliveryTaskClauses(normalized) {
131 clauseAffirmative := false
132 clauseNegated := false
133 if deliveryMutationClauseNegated(clause) {
134 clauseNegated = true
135 }
136 for _, needle := range deliveryMutationNeedles {
137 hasAffirmative, hasNegated := deliveryTaskNeedleIntent(clause, needle)
138 clauseAffirmative = clauseAffirmative || hasAffirmative
139 clauseNegated = clauseNegated || hasNegated
140 }
141 if clauseAffirmative && deliveryTaskClauseIsAdvisory(clause) && !deliveryTaskAdvisoryClauseRequestsMutation(clause) {
142 clauseAffirmative = false
143 clauseNegated = true
144 }
145 affirmative = affirmative || clauseAffirmative
146 negated = negated || clauseNegated
147 }
148 return affirmative, negated
149 }
150
151 func deliveryTaskIsAdvisory(input string) bool {
152 normalized := strings.ToLower(strings.TrimSpace(input))
153
154 // Concrete targets and commands always remain host-observable, including
155 // when the request is phrased as a "why" question.
156 if deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
157 return false
158 }
159
160 // Question wording is scoped per clause. This keeps remote troubleshooting
161 // such as "analyze why WPS won't open" advisory, while a separate imperative
162 // clause such as "reproduce the crash" still requires observable work.
163 sawAdvisory := false
164 for _, clause := range deliveryTaskClauses(normalized) {
165 if deliveryTaskClauseIsAdvisory(clause) {
166 sawAdvisory = true
167 continue
168 }
169 if deliveryTaskClauseHasObservableWork(clause) {
170 return false
171 }
172 }
173 if sawAdvisory {
174 return true
175 }
176
177 // A standalone refusal, inability, or constraint around a mutation verb is
178 // advisory rather than work Reasonix can perform. Affirmative mixed intent is
179 // handled by NeedsMutation before this function is consulted.
180 _, negatedMutation := deliveryTaskMutationIntent(normalized)
181 return negatedMutation
182 }
183
184 func deliveryTaskHasHostAnchor(input string) bool {
185 for _, anchor := range []string{
186 "this repo", "this repository", "current repository", "codebase", "workspace", "pull request", "this pr", "ci job",
187 "/pull/", "actions/runs/",
188 "当前仓库", "这个仓库", "当前项目", "这个项目", "代码库", "工作区", "这个 pr", "这个pr", "此 pr", "此pr",
189 } {
190 if strings.Contains(input, anchor) {
191 return true
192 }
193 }
194 return deliveryTaskHasFileReference(input)
195 }
196
197 func deliveryTaskHasFileReference(input string) bool {
198 previous := rune(0)
199 for index, current := range input {
200 if current == '@' && index+1 < len(input) &&
201 (index == 0 || strings.ContainsRune(" \t\r\n([{<,:;(【《,。;:", previous)) {
202 next, _ := utf8.DecodeRuneInString(input[index+1:])
203 if !strings.ContainsRune(" \t\r\n", next) {
204 return true
205 }
206 }
207 previous = current
208 }
209
210 for _, raw := range strings.FieldsFunc(input, func(r rune) bool {
211 switch r {
212 case ' ', '\t', '\r', '\n', '`', '\'', '"', '(', ')', '[', ']', '{', '}', '<', '>', ',', ',', ';', ';', '!', '!', '?', '?':
213 return true
214 default:
215 return false
216 }
217 }) {
218 token := strings.ToLower(strings.TrimSpace(raw))
219 if token == "" || strings.Contains(token, "://") {
220 continue
221 }
222 if strings.HasPrefix(token, "./") || strings.HasPrefix(token, "../") ||
223 strings.HasPrefix(token, "/") || strings.Contains(token, `\`) {
224 return true
225 }
226 base := token
227 if slash := strings.LastIndexByte(base, '/'); slash >= 0 {
228 base = base[slash+1:]
229 }
230 switch base {
231 case "dockerfile", "makefile", "cmakelists.txt", "justfile", "license", "readme", "changelog":
232 return true
233 }
234 dot := strings.LastIndexByte(base, '.')
235 if dot < 0 {
236 continue
237 }
238 switch base[dot:] {
239 case ".go", ".mod", ".sum", ".js", ".jsx", ".ts", ".tsx", ".py", ".rs", ".java", ".kt", ".swift",
240 ".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".sh", ".zsh", ".fish", ".ps1",
241 ".md", ".json", ".yaml", ".yml", ".toml", ".xml", ".sql", ".proto", ".html", ".css", ".scss",
242 ".vue", ".svelte", ".txt", ".log", ".csv", ".pdf", ".env", ".ini", ".conf", ".lock":
243 return true
244 }
245 }
246 return false
247 }
248
249 func deliveryTaskHasCommand(input string) bool {
250 tokens := strings.FieldsFunc(strings.ToLower(input), func(r rune) bool {
251 asciiWord := r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
252 return !asciiWord && r != '_' && r != '-' && r != '.' && r != '/' && r != '\\' && r != ':'
253 })
254 for i := range tokens {
255 if deliveryCommandStartsAt(tokens, i) {
256 return true
257 }
258 }
259 return false
260 }
261
262 func deliveryCommandStartsAt(tokens []string, index int) bool {
263 command := strings.TrimSpace(tokens[index])
264 if command == "" {
265 return false
266 }
267 if strings.HasPrefix(command, "./") || strings.HasPrefix(command, "../") ||
268 strings.HasPrefix(command, "/") || strings.Contains(command, `\`) {
269 return true
270 }
271 next := ""
272 if index+1 < len(tokens) {
273 next = tokens[index+1]
274 }
275 if next != "--" && len(next) > 1 && strings.HasPrefix(next, "-") {
276 return true
277 }
278 previous := ""
279 if index > 0 {
280 previous = tokens[index-1]
281 }
282 switch command {
283 case "go":
284 switch next {
285 case "build", "clean", "doc", "env", "fmt", "generate", "get", "install", "list", "mod", "run", "test", "tool", "version", "vet", "work":
286 return true
287 }
288 case "git", "npm", "npx", "pnpm", "yarn", "bun", "deno", "cargo", "rustc", "python", "python3",
289 "bash", "sh", "zsh", "fish", "powershell", "pwsh", "docker", "docker-compose", "kubectl", "helm", "terraform",
290 "gradle", "gradlew", "mvn", "dotnet", "xcodebuild", "gcc", "g++", "clang", "clang++":
291 return deliveryCommandHasExplicitCue(previous) || deliveryCommandHasSubcommand(next)
292 case "node":
293 return deliveryCommandHasExplicitCue(previous) || next == "inspect" || next == "test"
294 case "swift":
295 return next == "build" || next == "package" || next == "run" || next == "test"
296 case "make", "just":
297 switch next {
298 case "all", "build", "check", "clean", "fail", "failed", "failing", "install", "lint", "test":
299 return true
300 }
301 case "pytest", "cmake", "ninja", "eslint", "tsc", "vitest", "jest":
302 return deliveryCommandHasExplicitCue(previous) || next == "fail" || next == "failed" || next == "failing"
303 }
304 return false
305 }
306
307 func deliveryCommandHasExplicitCue(previous string) bool {
308 switch previous {
309 case "command", "execute", "executing", "run", "running", "using", "with":
310 return true
311 default:
312 return false
313 }
314 }
315
316 func deliveryCommandHasSubcommand(next string) bool {
317 switch next {
318 case "add", "apply", "branch", "build", "check", "checkout", "clean", "clone", "commit", "config", "container",
319 "deploy", "describe", "destroy", "dev", "diff", "down", "env", "exec", "fetch", "fmt", "generate", "get", "image",
320 "init", "install", "lint", "list", "log", "logs", "login", "logout", "merge", "mod", "package", "plan", "ps", "publish",
321 "pull", "push", "rebase", "remote", "remove", "reset", "restore", "run", "serve", "show", "start", "stash", "status",
322 "switch", "tag", "test", "tool", "uninstall", "up", "update", "upgrade", "version", "vet", "work", "worktree":
323 return true
324 default:
325 return false
326 }
327 }
328
329 func deliveryTaskClauseHasObservableWork(clause string) bool {
330 for _, needle := range []string{
331 "review", "inspect", "analyze", "check", "reproduce", "audit", "verify",
332 "评审", "审查", "检查", "分析", "复现", "审计", "验证",
333 } {
334 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
335 if affirmative {
336 return true
337 }
338 }
339 return false
340 }
341
342 func deliveryTaskClauseIsAdvisory(clause string) bool {
343 for _, phrase := range deliveryAdvisoryPhrases {
344 if strings.Contains(clause, phrase) {
345 return true
346 }
347 }
348 return false
349 }
350
351 func deliveryTaskAdvisoryClauseRequestsMutation(clause string) bool {
352 advisoryIndex := len(clause)
353 for _, phrase := range deliveryAdvisoryPhrases {
354 if index := strings.Index(clause, phrase); index >= 0 && index < advisoryIndex {
355 advisoryIndex = index
356 }
357 }
358 if advisoryIndex == len(clause) {
359 return false
360 }
361 if deliveryTaskStartsWithMutation(clause[:advisoryIndex]) {
362 return true
363 }
364
365 for _, cue := range []string{" please ", " then ", " so ", " therefore ", "然后", "所以", "而是", "转而"} {
366 for rest := clause[advisoryIndex:]; ; {
367 index := strings.Index(rest, cue)
368 if index < 0 {
369 break
370 }
371 rest = rest[index+len(cue):]
372 if deliveryTaskStartsWithMutation(rest) {
373 return true
374 }
375 }
376 }
377 for rest, offset := clause[advisoryIndex:], advisoryIndex; ; {
378 index := strings.Index(rest, "请")
379 if index < 0 {
380 break
381 }
382 absolute := offset + index
383 after := clause[absolute+len("请"):]
384 requestWord := strings.HasSuffix(clause[:absolute], "申") || strings.HasPrefix(after, "求")
385 if !requestWord && deliveryTaskStartsWithMutation(after) {
386 return true
387 }
388 offset = absolute + len("请")
389 rest = clause[offset:]
390 }
391
392 for _, cue := range []string{" and ", "并且", "并"} {
393 if index := strings.LastIndex(clause[advisoryIndex:], cue); index >= 0 {
394 cueStart := advisoryIndex + index
395 tail := clause[cueStart+len(cue):]
396 if !deliveryTaskClauseHasNegation(clause[:cueStart]) && deliveryTaskStartsWithMutation(tail) {
397 return true
398 }
399 }
400 }
401 return false
402 }
403
404 func deliveryTaskStartsWithMutation(input string) bool {
405 input = strings.TrimSpace(input)
406 for {
407 stripped := false
408 for _, prefix := range []string{"please ", "can you ", "could you ", "would you ", "you should ", "帮我", "请你", "直接", "继续", "再"} {
409 if after, ok := strings.CutPrefix(input, prefix); ok {
410 input = strings.TrimSpace(after)
411 stripped = true
412 break
413 }
414 }
415 if !stripped {
416 break
417 }
418 }
419 for _, needle := range deliveryMutationNeedles {
420 if containsTaskNeedle(input, needle) {
421 if goalBudgetContainsNonASCII(needle) {
422 return strings.HasPrefix(input, needle)
423 }
424 tokens := strings.FieldsFunc(input, func(r rune) bool {
425 return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
426 })
427 needleTokens := strings.Fields(needle)
428 if len(tokens) >= len(needleTokens) {
429 matches := true
430 for i := range needleTokens {
431 matches = matches && tokens[i] == needleTokens[i]
432 }
433 if matches {
434 return true
435 }
436 }
437 }
438 }
439 return false
440 }
441
442 func deliveryTaskClauseHasNegation(clause string) bool {
443 clause = strings.ReplaceAll(clause, "’", "'")
444 for _, phrase := range []string{
445 " not ", " never ", " without ", "cannot", "can't", " cant ", "don't", " dont ", "won't", " wont ", "unable",
446 "不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝",
447 } {
448 if strings.Contains(" "+clause+" ", phrase) {
449 return true
450 }
451 }
452 return false
453 }
454
455 func deliveryTaskClauses(input string) []string {
456 input = strings.NewReplacer(
457 " but ", "\n",
458 " however ", "\n",
459 " nevertheless ", "\n",
460 "但请", "\n请",
461 "但是", "\n",
462 "不过", "\n",
463 ).Replace(input)
464 return strings.FieldsFunc(input, func(r rune) bool {
465 switch r {
466 case '\n', '\r', '.', '。', ',', ',', ';', ';', '!', '!', '?', '?':
467 return true
468 default:
469 return false
470 }
471 })
472 }
473
474 func deliveryMutationClauseNegated(clause string) bool {
475 for _, phrase := range []string{
476 "without changing", "without modifying", "analysis only", "review only",
477 "不要改动", "只分析", "仅分析", "只检查", "仅检查", "只评审", "仅评审",
478 } {
479 if strings.Contains(clause, phrase) {
480 return true
481 }
482 }
483 return false
484 }
485
486 func deliveryTaskNeedleIntent(clause, needle string) (affirmative, negated bool) {
487 if goalBudgetContainsNonASCII(needle) {
488 for offset := 0; offset < len(clause); {
489 relative := strings.Index(clause[offset:], needle)
490 if relative < 0 {
491 break
492 }
493 index := offset + relative
494 prefix := []rune(clause[:index])
495 if deliveryMutationRunesNegated(prefix) {
496 negated = true
497 } else {
498 affirmative = true
499 }
500 offset = index + len(needle)
501 }
502 return affirmative, negated
503 }
504
505 clause = strings.ReplaceAll(clause, "’", "'")
506 tokens := strings.FieldsFunc(clause, func(r rune) bool {
507 return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
508 })
509 needleTokens := strings.Fields(needle)
510 for i := 0; i+len(needleTokens) <= len(tokens); i++ {
511 matches := true
512 for j, token := range needleTokens {
513 if tokens[i+j] != token {
514 matches = false
515 break
516 }
517 }
518 if !matches {
519 continue
520 }
521 if deliveryMutationTokensNegated(tokens[:i]) {
522 negated = true
523 } else {
524 affirmative = true
525 }
526 }
527 return affirmative, negated
528 }
529
530 func deliveryMutationTokensNegated(prefix []string) bool {
531 if len(prefix) > 6 {
532 prefix = prefix[len(prefix)-6:]
533 }
534 boundary := -1
535 for i, token := range prefix {
536 switch token {
537 case "but", "however", "nevertheless", "instead", "so", "then", "therefore", "please":
538 boundary = i
539 }
540 }
541 if boundary >= 0 {
542 prefix = prefix[boundary+1:]
543 }
544 for i, token := range prefix {
545 if token == "not" && i+1 < len(prefix) && prefix[i+1] == "only" {
546 continue
547 }
548 switch token {
549 case "not", "never", "without", "cannot", "can't", "cant", "don't", "dont", "won't", "wont", "unable", "avoid", "avoiding", "afraid", "refuse", "refusing", "needn't":
550 return true
551 case "no":
552 if i+1 < len(prefix) && prefix[i+1] == "need" {
553 return true
554 }
555 }
556 }
557 return false
558 }
559
560 func deliveryMutationRunesNegated(prefix []rune) bool {
561 if len(prefix) > 12 {
562 prefix = prefix[len(prefix)-12:]
563 }
564 window := string(prefix)
565 scopeStart := 0
566 for _, boundary := range []string{"所以", "然后", "而是", "转而", "改为"} {
567 if index := strings.LastIndex(window, boundary); index >= 0 {
568 end := index + len(boundary)
569 if end > scopeStart {
570 scopeStart = end
571 }
572 }
573 }
574 if index := strings.LastIndex(window, "请"); index >= 0 {
575 before, after := window[:index], window[index+len("请"):]
576 requestWord := strings.HasSuffix(before, "申") || strings.HasPrefix(after, "求")
577 negatedRequest := false
578 for _, marker := range []string{"不要", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "禁止", "拒绝"} {
579 if strings.HasSuffix(before, marker) || strings.Contains(after, marker) {
580 negatedRequest = true
581 break
582 }
583 }
584 if !requestWord && !negatedRequest && index+len("请") > scopeStart {
585 scopeStart = index + len("请")
586 }
587 }
588 window = window[scopeStart:]
589 for _, marker := range []string{"不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝"} {
590 if strings.Contains(window, marker) {
591 return true
592 }
593 }
594 return false
595 }
596
597 func containsAnySubstring(s string, terms []string) bool {
598 for _, term := range terms {
599 if strings.Contains(s, term) {
600 return true
601 }
602 }
603 return false
604 }
605
605 lines GO