diff --git a/internal/intent/proposal.go b/internal/intent/proposal.go new file mode 100644 index 00000000..59d192de --- /dev/null +++ b/internal/intent/proposal.go @@ -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 +} diff --git a/internal/intent/proposal_test.go b/internal/intent/proposal_test.go new file mode 100644 index 00000000..865ee7f5 --- /dev/null +++ b/internal/intent/proposal_test.go @@ -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) + } + } +} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 3c6d9396..f09345fc 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -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{ diff --git a/internal/intent/segmenter.go b/internal/intent/segmenter.go new file mode 100644 index 00000000..9c1fe8ee --- /dev/null +++ b/internal/intent/segmenter.go @@ -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 +} diff --git a/internal/intent/testdata/plan-proposal-eval-v1.json b/internal/intent/testdata/plan-proposal-eval-v1.json new file mode 100644 index 00000000..636331c4 --- /dev/null +++ b/internal/intent/testdata/plan-proposal-eval-v1.json @@ -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" + } + ] +} diff --git a/internal/intent/types.go b/internal/intent/types.go index ce8e0864..f6bacc5e 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -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 ) diff --git a/internal/intent/types_v2.go b/internal/intent/types_v2.go index 12cd2272..76f680e0 100644 --- a/internal/intent/types_v2.go +++ b/internal/intent/types_v2.go @@ -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"`