feat(intent): add rule segmenter and shadow PlanProposal

Generate 2–4 step L1 proposals with stable digests while leaving TopIntent and the legacy IntentPlan router unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-15 23:14:29 +00:00
co-authored by Cursor
parent 9005766b62
commit 932ba87daf
7 changed files with 1136 additions and 39 deletions
+303
View File
@@ -0,0 +1,303 @@
package intent
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
func attachPlanProposal(ctx context.Context, r *RuleBasedRecognizer, input string, snap *ContextSnapshot, res *IntentRecognitionResult) {
if res == nil {
return
}
proposal := buildPlanProposal(ctx, r, input, snap, res)
if proposal == nil {
return
}
res.PlanProposal = proposal
res.PlanShadow = buildPlanShadow(res, proposal)
}
func buildPlanProposal(ctx context.Context, r *RuleBasedRecognizer, input string, snap *ContextSnapshot, primary *IntentRecognitionResult) *PlanProposal {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil
}
if primary != nil && primary.TopIntent != nil && primary.TopIntent.Intent == IntentSecurityForbidden {
return rejectedProposal(trimmed, ComboRejected, "security_redline")
}
if isSecurityViolation(trimmed) && !isNegatedWriteWithReadonlyIntent(trimmed) {
return rejectedProposal(trimmed, ComboRejected, "security_redline")
}
rawSegs := SplitPlanSegments(input)
if len(rawSegs) == 0 {
return nil
}
if len(rawSegs) > maxPlanSegments {
p := &PlanProposal{
SegmenterVersion: SegmenterVersion,
ComboHint: ComboRejectedBatchSize,
Evidence: []IntentEvidence{{Kind: "batch_size", Detail: fmt.Sprintf("%d", len(rawSegs)), Weight: 1}},
}
for i, s := range rawSegs {
p.Segments = append(p.Segments, proposedFromInput(s, i, nil, nil))
}
p.Digest = proposalDigest(p)
return p
}
proposed := make([]ProposedSegment, 0, len(rawSegs))
for i, s := range rawSegs {
text := s.Normalized
if text == "" {
text = strings.TrimSpace(s.Text)
}
var frame *IntentFrameV2
var missing []string
reusePrimary := len(rawSegs) == 1 && primary != nil && (text == "" || text == normalizeSegmentText(input) || text == strings.TrimSpace(input))
if reusePrimary {
frame = primary.IntentFrameV2
if frame == nil {
frame = BuildIntentFrameV2(primary, DefaultRegistry())
}
if primary.TopIntent != nil {
missing = append([]string{}, primary.TopIntent.MissingSlots...)
}
} else {
leaf, err := r.recognizeCore(ctx, text, snap)
if err == nil && leaf != nil {
frame = BuildIntentFrameV2(leaf, DefaultRegistry())
if leaf.TopIntent != nil {
missing = append([]string{}, leaf.TopIntent.MissingSlots...)
}
if frame != nil && len(frame.MissingSlots) > 0 {
missing = append(missing, frame.MissingSlots...)
}
if leaf.TopIntent != nil && leaf.TopIntent.Intent == IntentSecurityForbidden {
p := rejectedProposal(trimmed, ComboRejected, "segment_security")
p.Segments = append(p.Segments, proposedFromInput(s, i, frame, missing))
p.Digest = proposalDigest(p)
return p
}
}
}
if frame != nil && len(frame.MissingSlots) > 0 {
missing = append(missing, frame.MissingSlots...)
}
seg := proposedFromInput(s, i, frame, uniqueStrings(missing))
if i > 0 {
seg.UnresolvedAnaphora = collectAnaphora(text)
seg.DependsOn = []string{fmt.Sprintf("%d", i)}
}
proposed = append(proposed, seg)
}
p := &PlanProposal{
SegmenterVersion: SegmenterVersion,
Segments: proposed,
}
p.ComboHint = comboHintFor(p.Segments, primary)
p.Evidence = comboEvidence(p)
p.Digest = proposalDigest(p)
return p
}
func proposedFromInput(s InputSegment, i int, frame *IntentFrameV2, missing []string) ProposedSegment {
return ProposedSegment{
StepID: fmt.Sprintf("%d", i+1),
Text: s.Text,
Normalized: s.Normalized,
Span: s.Span,
Connector: s.Connector,
Evidence: s.Evidence,
StructureConfidence: s.StructureConfidence,
Frame: frame,
MissingSlots: missing,
}
}
func comboHintFor(segs []ProposedSegment, primary *IntentRecognitionResult) ComboHint {
if len(segs) <= 1 {
return ComboSingle
}
needsClarify := false
needsConfirm := false
allQuery := true
seenKeys := map[string]int{}
for _, s := range segs {
if s.StructureConfidence == "low" || len(s.UnresolvedAnaphora) > 0 || len(s.MissingSlots) > 0 || s.Frame == nil {
needsClarify = true
}
if s.Frame == nil {
allQuery = false
continue
}
if s.Frame.Score > 0 && s.Frame.Score < 0.6 {
needsClarify = true
}
if isWriteIntent(s.Frame.LegacyIntent) || s.Frame.Goal == GoalGenerate || s.Frame.Goal == GoalMutate || s.Frame.Goal == GoalControl {
needsConfirm = true
allQuery = false
}
if s.Frame.Goal != GoalQuery && s.Frame.Goal != "" {
allQuery = false
}
if s.Frame.ActionKey != "" {
seenKeys[s.Frame.ActionKey]++
if seenKeys[s.Frame.ActionKey] > 1 {
needsClarify = true
}
}
}
if needsClarify {
return ComboNeedsClarify
}
if needsConfirm {
return ComboNeedsConfirm
}
if allQuery {
return ComboReadOnly
}
if primary != nil && primary.RequiresClarifying {
return ComboNeedsClarify
}
return ComboNeedsConfirm
}
func comboEvidence(p *PlanProposal) []IntentEvidence {
if p == nil {
return nil
}
ev := []IntentEvidence{{Kind: "segmenter", Detail: SegmenterVersion, Weight: 1}}
for _, s := range p.Segments {
if s.Evidence != "" {
ev = append(ev, IntentEvidence{Kind: "split", Detail: s.Evidence, Weight: 0.5})
}
}
return ev
}
func rejectedProposal(input string, hint ComboHint, reason string) *PlanProposal {
_ = input
p := &PlanProposal{
SegmenterVersion: SegmenterVersion,
ComboHint: hint,
Evidence: []IntentEvidence{{Kind: reason, Detail: string(hint), Weight: 1}},
Segments: []ProposedSegment{{
StepID: "1",
Text: input,
Normalized: normalizeSegmentText(input),
Span: TextSpan{Start: 0, End: len(input)},
StructureConfidence: "high",
Evidence: reason,
}},
}
p.Digest = proposalDigest(p)
return p
}
func proposalDigest(p *PlanProposal) string {
if p == nil {
return ""
}
h := sha256.New()
_, _ = h.Write([]byte(p.SegmenterVersion))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(p.ComboHint))
for _, s := range p.Segments {
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(s.Normalized))
_, _ = h.Write([]byte{0})
if s.Frame != nil {
_, _ = h.Write([]byte(s.Frame.ActionKey))
}
for _, a := range s.UnresolvedAnaphora {
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(a))
}
}
return hex.EncodeToString(h.Sum(nil))
}
func buildPlanShadow(res *IntentRecognitionResult, p *PlanProposal) *PlanShadowReport {
if p == nil {
return nil
}
dropped := false
if p.ComboHint == ComboNeedsConfirm || hasWriteSegment(p) {
dropped = res.IntentPlan == nil
}
return &PlanShadowReport{
Enabled: true,
SegmenterVersion: p.SegmenterVersion,
SegmentCount: len(p.Segments),
Digest: p.Digest,
ComboHint: p.ComboHint,
LegacyPlanAttached: res.IntentPlan != nil,
LegacyDroppedWrite: dropped && hasWriteSegment(p),
CounterfactualAutoRead: counterfactualAutoRead(p),
}
}
func hasWriteSegment(p *PlanProposal) bool {
if p == nil {
return false
}
for _, s := range p.Segments {
if s.Frame == nil {
continue
}
if isWriteIntent(s.Frame.LegacyIntent) || s.Frame.Goal == GoalGenerate || s.Frame.Goal == GoalMutate || s.Frame.Goal == GoalControl {
return true
}
}
return false
}
// CounterfactualAutoRead answers "if the system were in auto_read, what would it do?"
// It must not treat "did not execute because shadow" as zero error.
func counterfactualAutoRead(p *PlanProposal) string {
if p == nil {
return "would_clarify"
}
switch p.ComboHint {
case ComboReadOnly:
return "would_execute"
case ComboSingle:
if hasWriteSegment(p) {
return "would_confirm"
}
return "would_passthrough"
case ComboNeedsConfirm:
return "would_confirm"
case ComboNeedsClarify:
return "would_clarify"
case ComboRejected, ComboRejectedBatchSize:
return "would_reject"
default:
return "would_clarify"
}
}
func uniqueStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
var out []string
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+241
View File
@@ -0,0 +1,241 @@
package intent_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"chat-agent-system/internal/intent"
)
func TestSplitPlanSegments_ConnectorAndNumbered(t *testing.T) {
got := intent.SplitPlanSegments("查一下项目风险,然后查相关法规")
if len(got) != 2 {
t.Fatalf("connector split got %d %#v", len(got), got)
}
numbered := intent.SplitPlanSegments("1. 查一下相关法规\n2. 查相似案例\n3. 查一下我的权限")
if len(numbered) != 3 {
t.Fatalf("numbered split got %d %#v", len(numbered), numbered)
}
}
func TestSplitPlanSegments_PeriodAndSemicolon(t *testing.T) {
period := intent.SplitPlanSegments("查一下相关法规。查相似案例。")
if len(period) != 2 {
t.Fatalf("period split got %d %#v", len(period), period)
}
semi := intent.SplitPlanSegments("查一下相关法规;查相似案例")
if len(semi) != 2 {
t.Fatalf("semicolon split got %d %#v", len(semi), semi)
}
}
func TestSplitPlanSegments_InternalChainNotSplit(t *testing.T) {
got := intent.SplitPlanSegments("请查询相关法规并总结要点")
if len(got) != 1 {
t.Fatalf("internal chain must stay 1, got %d %#v", len(got), got)
}
}
func TestSplitPlanSegments_FirstThenFinally(t *testing.T) {
got := intent.SplitPlanSegments("先查一下相关法规再查相似案例最后查一下我的权限")
if len(got) != 3 {
t.Fatalf("first/then/finally got %d %#v", len(got), got)
}
}
func TestSplitPlanSegments_FiveNumberedKeptIntact(t *testing.T) {
got := intent.SplitPlanSegments("1. 查法规\n2. 查案例\n3. 查进度\n4. 查权限\n5. 查文档状态")
if len(got) != 5 {
t.Fatalf("must not truncate 5 segments, got %d", len(got))
}
}
func TestPlanProposal_WriteNotDroppedAndLegacyUnchanged(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "查一下相关法规,然后帮我生成报价单", nil)
if err != nil {
t.Fatal(err)
}
if res.IntentPlan != nil {
t.Fatal("production IntentPlan must stay nil")
}
if res.TopIntent == nil || res.TopIntent.Intent != intent.IntentQueryRegulations {
t.Fatalf("TopIntent changed: %+v", res.TopIntent)
}
if res.PlanProposal == nil || len(res.PlanProposal.Segments) < 2 {
t.Fatalf("proposal must keep both segments: %+v", res.PlanProposal)
}
if res.PlanProposal.ComboHint != intent.ComboNeedsConfirm {
t.Fatalf("combo hint = %s", res.PlanProposal.ComboHint)
}
if res.PlanShadow == nil || !res.PlanShadow.LegacyDroppedWrite {
t.Fatalf("shadow must record legacy write drop: %+v", res.PlanShadow)
}
if res.PlanShadow.CounterfactualAutoRead != "would_confirm" {
t.Fatalf("auto_read counterfactual = %s", res.PlanShadow.CounterfactualAutoRead)
}
}
func TestPlanProposal_QueryPairAutoReadCandidate(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "查一下项目风险,然后查相关法规", nil)
if err != nil {
t.Fatal(err)
}
if res.IntentPlan == nil {
t.Fatal("legacy plan still required")
}
if res.PlanProposal == nil || res.PlanProposal.ComboHint != intent.ComboReadOnly {
t.Fatalf("proposal %+v", res.PlanProposal)
}
if res.PlanShadow.CounterfactualAutoRead != "would_execute" {
t.Fatalf("counterfactual = %s", res.PlanShadow.CounterfactualAutoRead)
}
}
func TestPlanProposal_AnaphoraClarify(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "查一下相关法规,然后再看一下它的适用范围", nil)
if err != nil {
t.Fatal(err)
}
if res.PlanProposal == nil {
t.Fatal("missing proposal")
}
if res.PlanProposal.ComboHint != intent.ComboNeedsClarify {
t.Fatalf("anaphora must clarify, got %s segs=%+v", res.PlanProposal.ComboHint, res.PlanProposal.Segments)
}
if res.PlanShadow.CounterfactualAutoRead != "would_clarify" {
t.Fatalf("counterfactual = %s", res.PlanShadow.CounterfactualAutoRead)
}
}
func TestPlanProposal_SecurityRejectsWhole(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "查一下相关法规,然后修改我的工号", nil)
if err != nil {
t.Fatal(err)
}
if res.TopIntent == nil || res.TopIntent.Intent != intent.IntentSecurityForbidden {
t.Fatalf("security TopIntent got %+v", res.TopIntent)
}
if res.PlanProposal == nil || res.PlanProposal.ComboHint != intent.ComboRejected {
t.Fatalf("proposal %+v", res.PlanProposal)
}
if res.PlanShadow.CounterfactualAutoRead != "would_reject" {
t.Fatalf("counterfactual = %s", res.PlanShadow.CounterfactualAutoRead)
}
}
func TestPlanProposal_BatchSizeRejected(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "1. 查法规\n2. 查案例\n3. 查进度\n4. 查权限\n5. 查文档状态", nil)
if err != nil {
t.Fatal(err)
}
if res.PlanProposal == nil || res.PlanProposal.ComboHint != intent.ComboRejectedBatchSize {
t.Fatalf("proposal %+v", res.PlanProposal)
}
if len(res.PlanProposal.Segments) != 5 {
t.Fatalf("must keep 5 spans, got %d", len(res.PlanProposal.Segments))
}
}
func TestPlanProposal_DigestStable(t *testing.T) {
rec := intent.NewRecognizer()
input := "查一下项目风险,然后查相关法规"
a, err := rec.Recognize(context.Background(), input, nil)
if err != nil {
t.Fatal(err)
}
b, err := rec.Recognize(context.Background(), input, nil)
if err != nil {
t.Fatal(err)
}
if a.PlanProposal == nil || b.PlanProposal == nil || a.PlanProposal.Digest == "" {
t.Fatal("digest missing")
}
if a.PlanProposal.Digest != b.PlanProposal.Digest {
t.Fatalf("digest drifted %s vs %s", a.PlanProposal.Digest, b.PlanProposal.Digest)
}
}
func TestPlanProposalEvalGolden(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
raw, err := os.ReadFile(filepath.Join(filepath.Dir(file), "testdata", "plan-proposal-eval-v1.json"))
if err != nil {
t.Fatal(err)
}
var gf struct {
Cases []struct {
ID string `json:"id"`
Input string `json:"input"`
ExpectCombo string `json:"expect_combo"`
ExpectCounterfactual string `json:"expect_counterfactual"`
LegacyPlanSteps int `json:"legacy_plan_steps"`
HardNegative bool `json:"hard_negative"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &gf); err != nil {
t.Fatal(err)
}
rec := intent.NewRecognizer()
falseAuto := 0
denom := len(gf.Cases)
for _, c := range gf.Cases {
res, err := rec.Recognize(context.Background(), c.Input, nil)
if err != nil {
t.Fatalf("%s: %v", c.ID, err)
}
if res.PlanProposal == nil || res.PlanShadow == nil {
t.Fatalf("%s missing proposal/shadow", c.ID)
}
if string(res.PlanProposal.ComboHint) != c.ExpectCombo {
t.Errorf("%s combo=%s want %s", c.ID, res.PlanProposal.ComboHint, c.ExpectCombo)
}
if res.PlanShadow.CounterfactualAutoRead != c.ExpectCounterfactual {
t.Errorf("%s counterfactual=%s want %s", c.ID, res.PlanShadow.CounterfactualAutoRead, c.ExpectCounterfactual)
}
steps := 0
if res.IntentPlan != nil {
steps = len(res.IntentPlan.Steps)
}
if c.LegacyPlanSteps > 0 && steps != c.LegacyPlanSteps {
t.Errorf("%s legacy steps=%d want %d", c.ID, steps, c.LegacyPlanSteps)
}
if c.HardNegative && res.PlanShadow.CounterfactualAutoRead == "would_execute" {
falseAuto++
t.Errorf("%s hard-negative would auto-execute", c.ID)
}
}
if falseAuto != 0 {
t.Fatalf("false auto-execute %d / denom %d", falseAuto, denom)
}
t.Logf("plan-proposal eval denom=%d false_auto_execute=0", denom)
}
func TestPlanProposal_HardNegativeNoFalseAutoExecute(t *testing.T) {
rec := intent.NewRecognizer()
cases := []string{
"查一下相关法规,然后帮我生成报价单",
"查一下相关法规,然后修改我的工号",
"1. 查法规\n2. 查案例\n3. 查进度\n4. 查权限\n5. 查文档状态",
"查一下相关法规,然后再看一下它的适用范围",
}
for _, in := range cases {
res, err := rec.Recognize(context.Background(), in, nil)
if err != nil {
t.Fatal(err)
}
if res.PlanShadow != nil && res.PlanShadow.CounterfactualAutoRead == "would_execute" {
t.Fatalf("hard-negative %q would auto-execute", in)
}
}
}
+14 -5
View File
@@ -23,6 +23,15 @@ func NewRecognizer() Recognizer {
}
func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) {
result, err := r.recognizeCore(ctx, input, userCtx)
if err != nil || result == nil {
return result, err
}
attachPlanProposal(ctx, r, input, userCtx, result)
return result, nil
}
func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
result := &IntentRecognitionResult{
@@ -104,11 +113,11 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
// 2. 纯 UI 视窗与主题控制指令拦截:归入 CASUAL_CHAT 并给出不可执行提示
if isPureUICommand(trimmed) {
top := &CandidateIntent{
Intent: IntentCasualChat,
Branch: BranchNewIntentPreempt,
Confidence: 0.95,
FilledSlots: make(map[string]string),
Reasoning: "纯UI视窗或主题控制指令,系统暂不支持通过对话直接操作界面视窗布局或主题,请通过界面按钮或快捷键操作",
Intent: IntentCasualChat,
Branch: BranchNewIntentPreempt,
Confidence: 0.95,
FilledSlots: make(map[string]string),
Reasoning: "纯UI视窗或主题控制指令,系统暂不支持通过对话直接操作界面视窗布局或主题,请通过界面按钮或快捷键操作",
ClarificationQuestion: "检测到您下达了界面视窗或主题控制指令(如折叠/打开侧边栏、切换主题/深色浅色)。该操作属于前端UI视窗布局与主题控制,请直接点击界面右上角或侧边的视窗/主题切换按钮。",
}
result := &IntentRecognitionResult{
+414
View File
@@ -0,0 +1,414 @@
package intent
import (
"regexp"
"strings"
)
const maxPlanSegments = 4
var (
numberedItemHead = regexp.MustCompile(`(?:^|[\n;;])[\t ]*(?:[1-9]\s*[.、.)]|([1-9])|\([1-9]\)|[一二三四五]、)[\t ]*`)
asciiVersionDot = regexp.MustCompile(`(?i)(?:v?\d+)\.(?:\d)`)
internalChainPhrases = []string{
"查询并总结", "查询并汇总", "查询后总结", "查询后概括",
"查一下并总结", "查阅并总结", "检索并总结",
"阅读后概括", "阅读后总结", "阅读后汇总",
"读完后概括", "读完后总结", "阅读并概括",
}
anaphoraMarkers = []string{"这个", "那个", "它", "其", "上述", "刚才的", "该结果", "这份"}
)
type splitMark struct {
Token string
Kind string
}
var sequentialMarks = []splitMark{
{Token: ",然后", Kind: "connector"},
{Token: ",然后", Kind: "connector"},
{Token: ",接着", Kind: "connector"},
{Token: ",接着", Kind: "connector"},
{Token: ";再", Kind: "connector"},
{Token: ";再", Kind: "connector"},
{Token: ",并且", Kind: "connector"},
{Token: ",并且", Kind: "connector"},
{Token: ",并", Kind: "connector"},
{Token: ",并", Kind: "connector"},
{Token: "然后", Kind: "connector"},
{Token: "接着", Kind: "connector"},
{Token: ";", Kind: "semicolon"},
{Token: ";", Kind: "semicolon"},
{Token: "。", Kind: "period"},
}
// SplitPlanSegments deterministically splits an utterance into at most
// independent candidate spans. It never truncates a >4 split; the caller rejects.
func SplitPlanSegments(input string) []InputSegment {
origin := input
raw := strings.TrimSpace(input)
if raw == "" {
return nil
}
if isInternalSingleActionChain(raw) {
return []InputSegment{makeSegment(origin, 0, len(origin), "", "internal_chain", "high")}
}
if segs := splitNumbered(origin); len(segs) >= 2 {
return segs
}
if segs := splitFirstThenFinally(origin); len(segs) >= 2 {
return segs
}
if segs := splitNewlineClauses(origin); len(segs) >= 2 {
return segs
}
if segs := splitSequential(origin); len(segs) >= 2 {
return segs
}
return []InputSegment{makeSegment(origin, 0, len(origin), "", "single", "high")}
}
func isInternalSingleActionChain(input string) bool {
for _, p := range internalChainPhrases {
if strings.Contains(input, p) {
if strings.Contains(input, "然后") || strings.Contains(input, "接着") ||
strings.Contains(input, "最后") || numberedItemHead.MatchString(input) {
return false
}
return true
}
}
return false
}
func makeSegment(origin string, start, end int, connector, evidence, conf string) InputSegment {
if start < 0 {
start = 0
}
if end > len(origin) {
end = len(origin)
}
if start > end {
start, end = end, start
}
text := origin[start:end]
return InputSegment{
Text: text,
Normalized: normalizeSegmentText(text),
Span: TextSpan{Start: start, End: end},
Connector: connector,
Evidence: evidence,
StructureConfidence: conf,
}
}
func normalizeSegmentText(s string) string {
s = strings.TrimSpace(s)
fields := strings.Fields(s)
return strings.Join(fields, " ")
}
func splitNumbered(origin string) []InputSegment {
locs := numberedItemHead.FindAllStringIndex(origin, -1)
if len(locs) < 2 {
return nil
}
var segs []InputSegment
for i, loc := range locs {
start := loc[1]
end := len(origin)
if i+1 < len(locs) {
end = locs[i+1][0]
}
text := strings.TrimSpace(origin[start:end])
if text == "" {
return nil
}
conf := "high"
connector := strings.TrimSpace(origin[loc[0]:loc[1]])
segs = append(segs, makeSegment(origin, start, end, connector, "numbered", conf))
}
if len(segs) < 2 {
return nil
}
return segs
}
func splitFirstThenFinally(origin string) []InputSegment {
idxFirst := indexClauseToken(origin, "先")
if idxFirst < 0 {
return nil
}
type hit struct {
idx int
token string
}
var hits []hit
searchFrom := idxFirst + len("先")
for searchFrom < len(origin) {
next := earliestToken(origin[searchFrom:], []string{"最后", "然后再", "然后", "再"})
if next.idx < 0 {
break
}
abs := searchFrom + next.idx
hits = append(hits, hit{idx: abs, token: next.token})
searchFrom = abs + len(next.token)
}
if len(hits) == 0 {
return nil
}
var segs []InputSegment
firstEnd := hits[0].idx
firstText := strings.TrimSpace(origin[idxFirst:firstEnd])
if firstText == "" || firstText == "先" {
return nil
}
segs = append(segs, makeSegment(origin, idxFirst, firstEnd, "先", "first_then", "high"))
for i, h := range hits {
start := h.idx + len(h.token)
end := len(origin)
if i+1 < len(hits) {
end = hits[i+1].idx
}
if strings.TrimSpace(origin[start:end]) == "" {
return nil
}
segs = append(segs, makeSegment(origin, start, end, h.token, "first_then", "high"))
}
if len(segs) < 2 {
return nil
}
return segs
}
type tokenHit struct {
idx int
token string
}
func earliestToken(s string, tokens []string) tokenHit {
best := tokenHit{idx: -1}
for _, tok := range tokens {
i := strings.Index(s, tok)
if i < 0 {
continue
}
if best.idx < 0 || i < best.idx || (i == best.idx && len(tok) > len(best.token)) {
best = tokenHit{idx: i, token: tok}
}
}
return best
}
func indexClauseToken(s, tok string) int {
from := 0
for from < len(s) {
rel := strings.Index(s[from:], tok)
if rel < 0 {
return -1
}
i := from + rel
if tok == "先" && i >= len("优") && s[i-len("优"):i] == "优" {
from = i + len(tok)
continue
}
return i
}
return -1
}
func splitNewlineClauses(origin string) []InputSegment {
if !strings.Contains(origin, "\n") {
return nil
}
parts := strings.Split(origin, "\n")
type piece struct {
text string
start, end int
}
var pieces []piece
cursor := 0
for _, p := range parts {
start := cursor
end := cursor + len(p)
cursor = end + 1
trim := strings.TrimSpace(p)
if trim == "" {
continue
}
if !looksLikeIntentClause(trim) {
return nil
}
pieces = append(pieces, piece{text: p, start: start, end: end})
}
if len(pieces) < 2 || len(pieces) > maxPlanSegments {
if len(pieces) > maxPlanSegments {
var segs []InputSegment
for i, p := range pieces {
conn := "newline"
if i == 0 {
conn = ""
}
segs = append(segs, makeSegment(origin, p.start, p.end, conn, "newline", "medium"))
}
return segs
}
return nil
}
var segs []InputSegment
for i, p := range pieces {
conn := "newline"
if i == 0 {
conn = ""
}
segs = append(segs, makeSegment(origin, p.start, p.end, conn, "newline", "medium"))
}
return segs
}
func looksLikeIntentClause(s string) bool {
markers := []string{"查", "查询", "生成", "推进", "认领", "送审", "批准", "驳回", "总结", "看看", "帮我", "请"}
for _, m := range markers {
if strings.Contains(s, m) {
return true
}
}
return false
}
func splitSequential(origin string) []InputSegment {
var segs []InputSegment
start := 0
incoming := ""
for start < len(origin) {
rel, mark := nextSequentialSplit(origin[start:])
if rel < 0 {
if strings.TrimSpace(origin[start:]) == "" {
break
}
evidence := "sequential"
conf := "high"
if incoming == "" && len(segs) == 0 {
evidence = "single"
}
if markKindOf(incoming) == "period" {
conf = "medium"
}
segs = append(segs, makeSegment(origin, start, len(origin), incoming, evidence, conf))
break
}
abs := start + rel
if strings.TrimSpace(origin[start:abs]) == "" {
start = abs + len(mark.Token)
incoming = mark.Token
continue
}
segs = append(segs, makeSegment(origin, start, abs, incoming, mark.Kind, confidenceForMark(mark.Kind)))
incoming = mark.Token
start = abs + len(mark.Token)
}
if len(segs) < 2 {
return nil
}
return segs
}
func markKindOf(token string) string {
for _, m := range sequentialMarks {
if m.Token == token {
return m.Kind
}
}
return ""
}
func nextSequentialSplit(s string) (int, splitMark) {
bestIdx := -1
best := splitMark{}
for _, m := range sequentialMarks {
idx := 0
for idx < len(s) {
rel := strings.Index(s[idx:], m.Token)
if rel < 0 {
break
}
i := idx + rel
if i <= 0 {
idx = i + len(m.Token)
continue
}
if m.Kind == "period" && asciiVersionDot.MatchString(windowAround(s, i, 4)) {
idx = i + len(m.Token)
continue
}
if isAndSplit(m.Token) && isInternalChainSplit(s, i, m.Token) {
idx = i + len(m.Token)
continue
}
if bestIdx < 0 || i < bestIdx || (i == bestIdx && len(m.Token) > len(best.Token)) {
bestIdx = i
best = m
}
break
}
}
return bestIdx, best
}
func isAndSplit(token string) bool {
switch token {
case ",并", ",并", ",并且", ",并且":
return true
default:
return false
}
}
func confidenceForMark(kind string) string {
switch kind {
case "period":
return "medium"
default:
return "high"
}
}
func windowAround(s string, i, n int) string {
start := i - n
if start < 0 {
start = 0
}
end := i + n
if end > len(s) {
end = len(s)
}
return s[start:end]
}
func isInternalChainSplit(s string, idx int, sep string) bool {
start := idx - 12
if start < 0 {
start = 0
}
end := idx + len(sep) + 12
if end > len(s) {
end = len(s)
}
around := s[start:end]
for _, p := range internalChainPhrases {
if strings.Contains(around, p) {
return true
}
}
return false
}
func collectAnaphora(text string) []string {
var out []string
for _, m := range anaphoraMarkers {
if strings.Contains(text, m) {
out = append(out, m)
}
}
return out
}
+61
View File
@@ -0,0 +1,61 @@
{
"version": "plan-proposal-eval-v1",
"notes": "U2 shadow counterfactual: would_execute is only allowed for independent low-risk read pairs. Denominator is the cases array length.",
"cases": [
{
"id": "query-then-query",
"input": "查一下项目风险,然后查相关法规",
"expect_combo": "read_only",
"expect_counterfactual": "would_execute",
"legacy_plan_steps": 2
},
{
"id": "query-then-generate",
"input": "查一下相关法规,然后帮我生成报价单",
"expect_combo": "needs_confirm",
"expect_counterfactual": "would_confirm",
"legacy_plan_steps": 0,
"hard_negative": true
},
{
"id": "internal-chain",
"input": "请查询相关法规并总结要点",
"expect_combo": "single",
"expect_counterfactual": "would_passthrough",
"legacy_plan_steps": 0
},
{
"id": "anaphora",
"input": "查一下相关法规,然后再看一下它的适用范围",
"expect_combo": "needs_clarify",
"expect_counterfactual": "would_clarify",
"hard_negative": true
},
{
"id": "security",
"input": "查一下相关法规,然后修改我的工号",
"expect_combo": "rejected",
"expect_counterfactual": "would_reject",
"hard_negative": true
},
{
"id": "batch-size-5",
"input": "1. 查法规\n2. 查案例\n3. 查进度\n4. 查权限\n5. 查文档状态",
"expect_combo": "rejected_batch_size",
"expect_counterfactual": "would_reject",
"hard_negative": true
},
{
"id": "numbered-3-query",
"input": "1. 查一下相关法规\n2. 查相似案例\n3. 查一下我的权限",
"expect_combo": "read_only",
"expect_counterfactual": "would_execute"
},
{
"id": "first-then-finally",
"input": "先查一下相关法规再查相似案例最后查一下我的权限",
"expect_combo": "read_only",
"expect_counterfactual": "would_execute"
}
]
}
+17 -15
View File
@@ -26,13 +26,13 @@ const (
IntentDocGovProposalDecide IntentType = "DOCGOV_PROPOSAL_DECIDE"
// 决策与合规查询
IntentQueryRegulations IntentType = "QUERY_REGULATIONS"
IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES"
IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS"
IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因
IntentQueryRegulations IntentType = "QUERY_REGULATIONS"
IntentQuerySimilarCases IntentType = "QUERY_SIMILAR_CASES"
IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS"
IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因
// 协同拍板与阶段执行动作
IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片
IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片
IntentExecuteStageAdvance IntentType = "EXECUTE_STAGE_ADVANCE" // 推进工作流阶段执行
// 安全与权限红线拦截
@@ -67,11 +67,11 @@ const (
// ExpectationOption 供用户挑选或单字快速兑现的具体选项
type ExpectationOption struct {
OptionID string `json:"option_id"` // 选项代号 (如 "1", "2", "advance_v05")
Title string `json:"title"` // 选项简述 (如 "推进到 V0.5 方案成型与法规对齐")
Description string `json:"description,omitempty"` // 参谋为用户做的工作说明与收益分析
Intent IntentType `json:"intent"` // 对应绑定的业务意图
Slots map[string]string `json:"slots,omitempty"` // 预设槽位键值对
OptionID string `json:"option_id"` // 选项代号 (如 "1", "2", "advance_v05")
Title string `json:"title"` // 选项简述 (如 "推进到 V0.5 方案成型与法规对齐")
Description string `json:"description,omitempty"` // 参谋为用户做的工作说明与收益分析
Intent IntentType `json:"intent"` // 对应绑定的业务意图
Slots map[string]string `json:"slots,omitempty"` // 预设槽位键值对
}
// PendingExpectation 挂起预期草稿(非阻塞桌角备忘贴纸)
@@ -121,9 +121,11 @@ type IntentRecognitionResult struct {
ShadowDecisionHint string `json:"shadow_decision_hint,omitempty"`
// V2 双写:迁移期与 TopIntent 并存,Router/Coordinator 默认仍消费 TopIntent。
IntentFrameV2 *IntentFrameV2 `json:"intent_frame_v2,omitempty"`
RankedFrames *RankedIntentSet `json:"ranked_frames,omitempty"`
IntentPlan *IntentPlan `json:"intent_plan,omitempty"`
IntentFrameV2 *IntentFrameV2 `json:"intent_frame_v2,omitempty"`
RankedFrames *RankedIntentSet `json:"ranked_frames,omitempty"`
IntentPlan *IntentPlan `json:"intent_plan,omitempty"`
PlanProposal *PlanProposal `json:"plan_proposal,omitempty"`
PlanShadow *PlanShadowReport `json:"plan_shadow,omitempty"`
// V2Shadow Phase 5 影子比对(intent_v2_shadow / intent_v2_decision 开启时填充)
V2Shadow *V2ShadowReport `json:"v2_shadow,omitempty"`
@@ -152,8 +154,8 @@ const (
SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main
// 长任务/运行槽位
SlotWorkDir = "work_dir"
SlotRunAction = "run_action" // status | resume | restart
SlotWorkDir = "work_dir"
SlotRunAction = "run_action" // status | resume | restart
SlotSupplementAction = "supplement_action" // add | correct | attach
)
+86 -19
View File
@@ -2,23 +2,23 @@ package intent
// IntentFrameV2 语义帧 V2(与 Legacy IntentType 并存,迁移期双写)
type IntentFrameV2 struct {
Version string `json:"version"`
ActionKey string `json:"action_key"`
Domain string `json:"domain"`
Object string `json:"object"`
Goal GoalType `json:"goal"`
DialogueAct DialogueAct `json:"dialogue_act"`
Version string `json:"version"`
ActionKey string `json:"action_key"`
Domain string `json:"domain"`
Object string `json:"object"`
Goal GoalType `json:"goal"`
DialogueAct DialogueAct `json:"dialogue_act"`
Slots map[string]SlotValue `json:"slots,omitempty"`
MissingSlots []string `json:"missing_slots,omitempty"`
Relation ExpectationRelation `json:"relation"`
Evidence []IntentEvidence `json:"evidence,omitempty"`
Score float64 `json:"score"`
Margin float64 `json:"margin,omitempty"`
Relation ExpectationRelation `json:"relation"`
Evidence []IntentEvidence `json:"evidence,omitempty"`
Score float64 `json:"score"`
Margin float64 `json:"margin,omitempty"`
LegacyIntent IntentType `json:"legacy_intent,omitempty"`
Subtype string `json:"subtype,omitempty"`
LegacyIntent IntentType `json:"legacy_intent,omitempty"`
Subtype string `json:"subtype,omitempty"`
}
// GoalType 用户目标分类(相对稳定)
@@ -47,9 +47,9 @@ const (
// IntentEvidence 规则命中证据(非统计概率)
type IntentEvidence struct {
Kind string `json:"kind"`
Detail string `json:"detail"`
Weight float64 `json:"weight,omitempty"`
Kind string `json:"kind"`
Detail string `json:"detail"`
Weight float64 `json:"weight,omitempty"`
}
// SlotValue V2 槽位值
@@ -68,14 +68,81 @@ type RankedIntentSet struct {
// CandidateConflict 候选冲突描述
type CandidateConflict struct {
Kind string `json:"kind"`
TopAction string `json:"top_action"`
SecondAction string `json:"second_action"`
Margin float64 `json:"margin"`
Kind string `json:"kind"`
TopAction string `json:"top_action"`
SecondAction string `json:"second_action"`
Margin float64 `json:"margin"`
}
const IntentFrameV2Version = "v2.0"
const SegmenterVersion = "segmenter.v2"
// ComboHint is a structural L1 recommendation. It is not authorization.
type ComboHint string
const (
ComboSingle ComboHint = "single"
ComboReadOnly ComboHint = "read_only"
ComboNeedsConfirm ComboHint = "needs_confirm"
ComboNeedsClarify ComboHint = "needs_clarify"
ComboRejected ComboHint = "rejected"
ComboRejectedBatchSize ComboHint = "rejected_batch_size"
)
// TextSpan is a byte offset into the original utterance.
type TextSpan struct {
Start int `json:"start"`
End int `json:"end"`
}
// InputSegment is a deterministic split of the original text.
type InputSegment struct {
Text string `json:"text"`
Normalized string `json:"normalized"`
Span TextSpan `json:"span"`
Connector string `json:"connector,omitempty"`
Evidence string `json:"evidence,omitempty"`
StructureConfidence string `json:"structure_confidence"`
}
// ProposedSegment is a recognized L1 candidate for one span.
type ProposedSegment struct {
StepID string `json:"step_id"`
Text string `json:"text"`
Normalized string `json:"normalized"`
Span TextSpan `json:"span"`
Connector string `json:"connector,omitempty"`
Evidence string `json:"evidence,omitempty"`
StructureConfidence string `json:"structure_confidence"`
UnresolvedAnaphora []string `json:"unresolved_anaphora,omitempty"`
Frame *IntentFrameV2 `json:"frame,omitempty"`
MissingSlots []string `json:"missing_slots,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
// PlanProposal is the L1 value object for 1–4 candidate steps.
// It contains no authorization conclusion and no DB state.
type PlanProposal struct {
SegmenterVersion string `json:"segmenter_version"`
Digest string `json:"digest"`
ComboHint ComboHint `json:"combo_hint"`
Segments []ProposedSegment `json:"segments"`
Evidence []IntentEvidence `json:"evidence,omitempty"`
}
// PlanShadowReport is observation-only. Coordinator must not route on it.
type PlanShadowReport struct {
Enabled bool `json:"enabled"`
SegmenterVersion string `json:"segmenter_version"`
SegmentCount int `json:"segment_count"`
Digest string `json:"digest,omitempty"`
ComboHint ComboHint `json:"combo_hint,omitempty"`
LegacyPlanAttached bool `json:"legacy_plan_attached"`
LegacyDroppedWrite bool `json:"legacy_dropped_write,omitempty"`
CounterfactualAutoRead string `json:"counterfactual_auto_read,omitempty"`
}
// IntentPlan 有限多意图执行计划(Phase 4:仅支持可验证组合)
type IntentPlan struct {
Steps []IntentPlanStep `json:"steps"`