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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user