Files
Agent-System/internal/workflowscope/score.go
T
weichenandCursor 976f8d30b8 feat(server): allow weighted cross-workflow reads after a view check
Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 09:11:47 +00:00

228 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package workflowscope
import (
"strings"
"unicode"
"unicode/utf8"
)
const (
// QualityExact 标题 / 任务键 / 文档 ID 原文精确命中。
QualityExact = 1.0
// QualityNormalized 归一化后精确命中。
QualityNormalized = 0.85
// CrossMargin 他流要胜出,分数必须高出激活流最佳分这么多。
CrossMargin = 0.15
// MinCrossQuality 模糊包含的质量上限低于此值,因此不能让他流胜出。
MinCrossQuality = 0.85
)
// Action 区分只读放行与写动作拦截。
type Action string
const (
ActionRead Action = "read"
ActionWrite Action = "write"
)
// Kind 是裁决结果。
type Kind string
const (
KindNone Kind = ""
KindInScope Kind = "in_scope"
KindCrossAllow Kind = "cross_allow"
KindCrossBlock Kind = "cross_block"
)
// Candidate 是一条文档命中。Owner 为空表示归属未知,视作可留在池内。
type Candidate struct {
ID string
Label string
Owner string
Quality float64
}
// Decision 携带胜出项。KindNone 表示没有足够把握的命中。
type Decision struct {
Kind Kind
Winner Candidate
Score float64
}
// Decide 在激活工作流约束下选出文档。
// active 为空:不过滤,按质量取最高并记为 InScope。
// strict:先取激活流(含归属未知),没有才在质量达到精确档的他流上 CrossBlock。
// weighted:score = quality × 关系权重;他流仅在分差与精确质量同时满足时胜出。
func Decide(candidates []Candidate, active string, action Action) Decision {
usable := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
if c.Quality <= 0 {
continue
}
usable = append(usable, c)
}
if len(usable) == 0 {
return Decision{}
}
if strings.TrimSpace(active) == "" {
return bestOf(usable, active, func(c Candidate) float64 { return c.Quality }, KindInScope)
}
if CurrentMode() != ModeWeighted {
return decideStrict(usable, active)
}
return decideWeighted(usable, active, action)
}
func decideStrict(candidates []Candidate, active string) Decision {
var inScope []Candidate
var cross []Candidate
for _, c := range candidates {
if c.Owner == "" || c.Owner == active {
inScope = append(inScope, c)
continue
}
cross = append(cross, c)
}
if len(inScope) > 0 {
return bestOf(inScope, active, func(c Candidate) float64 { return c.Quality }, KindInScope)
}
exact := make([]Candidate, 0, len(cross))
for _, c := range cross {
if c.Quality >= MinCrossQuality {
exact = append(exact, c)
}
}
if len(exact) == 0 {
return Decision{}
}
return bestOf(exact, active, func(c Candidate) float64 { return c.Quality }, KindCrossBlock)
}
func decideWeighted(candidates []Candidate, active string, action Action) Decision {
scoreOf := func(c Candidate) float64 {
return c.Quality * RelationWeight(active, c.Owner)
}
var bestIn, bestCross Candidate
inScore, crossScore := -1.0, -1.0
for _, c := range candidates {
s := scoreOf(c)
if c.Owner == "" || c.Owner == active {
if s > inScore {
inScore = s
bestIn = c
}
continue
}
if s > crossScore || (s == crossScore && c.Quality > bestCross.Quality) {
crossScore = s
bestCross = c
}
}
if crossScore < 0 {
return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore}
}
baseline := 0.0
if inScore >= 0 {
baseline = inScore
}
crossWins := bestCross.Quality >= MinCrossQuality && crossScore >= baseline+CrossMargin
if !crossWins {
if inScore >= 0 {
return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore}
}
return Decision{}
}
kind := KindCrossAllow
if action == ActionWrite {
kind = KindCrossBlock
}
return Decision{Kind: kind, Winner: bestCross, Score: crossScore}
}
func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float64, kind Kind) Decision {
best := candidates[0]
bestScore := scoreOf(best)
for _, c := range candidates[1:] {
s := scoreOf(c)
if s > bestScore {
best = c
bestScore = s
}
}
if kind == KindInScope && active != "" && best.Owner != "" && best.Owner != active {
kind = KindCrossBlock
}
return Decision{Kind: kind, Winner: best, Score: bestScore}
}
// BlocksWrite 报告一个已确定归属的任务键在写路径上是否应拦截。
// 归属未知或未激活时不拦截。
func BlocksWrite(active, owner, id string) bool {
if strings.TrimSpace(active) == "" || strings.TrimSpace(owner) == "" || owner == active {
return false
}
decision := Decide([]Candidate{{
ID: id,
Label: id,
Owner: owner,
Quality: QualityExact,
}}, active, ActionWrite)
return decision.Kind == KindCrossBlock
}
// BestQuality 取 query 对多个字段的最高匹配质量。
func BestQuality(query string, fields ...string) float64 {
best := 0.0
for _, field := range fields {
if q := ContainmentQuality(query, field); q > best {
best = q
}
}
return best
}
// ContainmentQuality 三档质量:原文精确 1、归一化精确 0.85、双向包含 0.5×长度比。
func ContainmentQuality(query, field string) float64 {
query = strings.TrimSpace(query)
field = strings.TrimSpace(field)
if query == "" || field == "" {
return 0
}
if query == field {
return QualityExact
}
nq := NormalizeKey(query)
nf := NormalizeKey(field)
if nq == "" || nf == "" {
return 0
}
if nq == nf {
return QualityNormalized
}
if strings.Contains(nf, nq) || strings.Contains(nq, nf) {
shorter := utf8.RuneCountInString(nq)
longer := utf8.RuneCountInString(nf)
if shorter > longer {
shorter, longer = longer, shorter
}
if longer == 0 {
return 0
}
return 0.5 * float64(shorter) / float64(longer)
}
return 0
}
// NormalizeKey 去掉空白、标点和无区分度的虚词,供模糊比较。
func NormalizeKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.NewReplacer("收集", "", "采集", "", "正文", "", "文档", "", "《", "", "》", "").Replace(value)
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) || unicode.IsPunct(r) {
return -1
}
return r
}, value)
}