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>
228 lines
6.0 KiB
Go
228 lines
6.0 KiB
Go
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)
|
||
}
|