feat(intent): add V2 frame, ActionSpec registry, and legacy dual-write
Introduce IntentFrameV2 with goal/object/relation/evidence, register all 21 legacy intents in ActionSpec registry, and dual-write intent_frame_v2 alongside TopIntent without changing Router/Coordinator routing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package intent
|
||||
|
||||
import "strings"
|
||||
|
||||
// branchToRelation 将三分支映射为 ExpectationRelation
|
||||
func branchToRelation(branch ResolutionBranch, cand *CandidateIntent) ExpectationRelation {
|
||||
switch branch {
|
||||
case BranchExpectationHit:
|
||||
if cand != nil && cand.IsDecline {
|
||||
return RelationExpectationDecline
|
||||
}
|
||||
if cand != nil && len(cand.PatchSlots) > 0 {
|
||||
return RelationExpectationPatch
|
||||
}
|
||||
return RelationExpectationHit
|
||||
case BranchAmbiguousFallback:
|
||||
return RelationUnresolvedReference
|
||||
default:
|
||||
return RelationNew
|
||||
}
|
||||
}
|
||||
|
||||
// BuildIntentFrameV2 从 Legacy 候选意图构建 V2 语义帧
|
||||
func BuildIntentFrameV2(res *IntentRecognitionResult, registry *ActionRegistry) *IntentFrameV2 {
|
||||
if res == nil || res.TopIntent == nil {
|
||||
return nil
|
||||
}
|
||||
if registry == nil {
|
||||
registry = DefaultRegistry()
|
||||
}
|
||||
top := res.TopIntent
|
||||
spec, ok := registry.GetByLegacyIntent(top.Intent)
|
||||
frame := &IntentFrameV2{
|
||||
Version: IntentFrameV2Version,
|
||||
LegacyIntent: top.Intent,
|
||||
Relation: branchToRelation(res.Branch, top),
|
||||
Score: top.Confidence,
|
||||
Margin: res.ShadowMargin,
|
||||
DialogueAct: detectDialogueAct(res.RawInput),
|
||||
MissingSlots: append([]string(nil), top.MissingSlots...),
|
||||
}
|
||||
if len(res.Candidates) >= 2 {
|
||||
frame.Margin = res.Candidates[0].Confidence - res.Candidates[1].Confidence
|
||||
}
|
||||
if ok {
|
||||
frame.ActionKey = spec.Key
|
||||
frame.Domain = spec.Domain
|
||||
frame.Object = spec.Object
|
||||
frame.Goal = spec.Goal
|
||||
} else {
|
||||
frame.ActionKey = "unknown." + strings.ToLower(string(top.Intent))
|
||||
frame.Domain = "unknown"
|
||||
frame.Object = "unknown"
|
||||
frame.Goal = GoalConverse
|
||||
}
|
||||
if top.FilledSlots != nil {
|
||||
frame.Slots = make(map[string]SlotValue, len(top.FilledSlots))
|
||||
for k, v := range top.FilledSlots {
|
||||
frame.Slots[k] = SlotValue{Value: v, Source: "rule"}
|
||||
}
|
||||
}
|
||||
if top.FilledSlots != nil {
|
||||
if topic, ok := top.FilledSlots[SlotQueryTopic]; ok && top.Intent == IntentQueryProgress {
|
||||
frame.Subtype = topic
|
||||
}
|
||||
}
|
||||
frame.Evidence = buildEvidenceFromCandidate(top, res)
|
||||
return frame
|
||||
}
|
||||
|
||||
func buildEvidenceFromCandidate(top *CandidateIntent, res *IntentRecognitionResult) []IntentEvidence {
|
||||
var ev []IntentEvidence
|
||||
if top.Reasoning != "" {
|
||||
ev = append(ev, IntentEvidence{Kind: "rule_match", Detail: top.Reasoning, Weight: top.Confidence})
|
||||
}
|
||||
if res.ShadowDialogueAct != "" {
|
||||
ev = append(ev, IntentEvidence{Kind: "dialogue_act", Detail: res.ShadowDialogueAct, Weight: 0.1})
|
||||
}
|
||||
if strings.Contains(top.Reasoning, "权限不符") {
|
||||
ev = append(ev, IntentEvidence{Kind: "policy_hint", Detail: "permission_mismatch_l1", Weight: 0.0})
|
||||
}
|
||||
if len(top.MissingSlots) > 0 {
|
||||
ev = append(ev, IntentEvidence{Kind: "missing_slots", Detail: strings.Join(top.MissingSlots, ","), Weight: 0.0})
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// BuildRankedIntentSet 从识别结果构建排序候选帧集合
|
||||
func BuildRankedIntentSet(res *IntentRecognitionResult, registry *ActionRegistry) *RankedIntentSet {
|
||||
if res == nil || len(res.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if registry == nil {
|
||||
registry = DefaultRegistry()
|
||||
}
|
||||
set := &RankedIntentSet{TopMargin: res.ShadowMargin}
|
||||
if len(res.Candidates) >= 2 {
|
||||
set.TopMargin = res.Candidates[0].Confidence - res.Candidates[1].Confidence
|
||||
}
|
||||
for i := range res.Candidates {
|
||||
c := res.Candidates[i]
|
||||
tmp := &IntentRecognitionResult{
|
||||
RawInput: res.RawInput,
|
||||
Branch: res.Branch,
|
||||
TopIntent: &c,
|
||||
Candidates: res.Candidates,
|
||||
}
|
||||
if fr := BuildIntentFrameV2(tmp, registry); fr != nil {
|
||||
set.Candidates = append(set.Candidates, *fr)
|
||||
}
|
||||
}
|
||||
if len(res.Candidates) >= 2 {
|
||||
top := res.Candidates[0]
|
||||
second := res.Candidates[1]
|
||||
margin := top.Confidence - second.Confidence
|
||||
if margin < 0.05 && ((isWriteIntent(top.Intent) && isQueryIntent(second.Intent)) ||
|
||||
(isQueryIntent(top.Intent) && isWriteIntent(second.Intent))) {
|
||||
set.Conflicts = append(set.Conflicts, CandidateConflict{
|
||||
Kind: "write_vs_query",
|
||||
TopAction: string(top.Intent),
|
||||
SecondAction: string(second.Intent),
|
||||
Margin: margin,
|
||||
})
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// LegacyIntentFromFrame 从 V2 帧还原 Legacy TopIntent(契约测试与灰度回退)
|
||||
func LegacyIntentFromFrame(frame *IntentFrameV2) IntentType {
|
||||
if frame == nil {
|
||||
return IntentCasualChat
|
||||
}
|
||||
if frame.LegacyIntent != "" {
|
||||
return frame.LegacyIntent
|
||||
}
|
||||
if spec, ok := DefaultRegistry().Get(frame.ActionKey); ok {
|
||||
return spec.LegacyIntent
|
||||
}
|
||||
return IntentCasualChat
|
||||
}
|
||||
|
||||
// AttachIntentFrameV2 为识别结果附加 V2 双写字段(不改变 TopIntent 路由)
|
||||
func AttachIntentFrameV2(res *IntentRecognitionResult) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
res.IntentFrameV2 = BuildIntentFrameV2(res, DefaultRegistry())
|
||||
res.RankedFrames = BuildRankedIntentSet(res, DefaultRegistry())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
)
|
||||
|
||||
func TestBuildIntentFrameV2_FromCandidate(t *testing.T) {
|
||||
res := &IntentRecognitionResult{
|
||||
RawInput: "先帮我出一版初步报价单看看",
|
||||
Branch: BranchNewIntentPreempt,
|
||||
TopIntent: &CandidateIntent{
|
||||
Intent: IntentTriggerTask,
|
||||
Confidence: 0.92,
|
||||
FilledSlots: map[string]string{
|
||||
SlotTargetTask: "cdo_quote",
|
||||
},
|
||||
Reasoning: "触发交付任务",
|
||||
},
|
||||
Candidates: []CandidateIntent{
|
||||
{Intent: IntentTriggerTask, Confidence: 0.92},
|
||||
{Intent: IntentCasualChat, Confidence: 0.20},
|
||||
},
|
||||
}
|
||||
enrichShadowFields(res)
|
||||
frame := BuildIntentFrameV2(res, DefaultRegistry())
|
||||
if frame == nil {
|
||||
t.Fatal("expected frame")
|
||||
}
|
||||
if frame.Version != IntentFrameV2Version {
|
||||
t.Errorf("version: want %s got %s", IntentFrameV2Version, frame.Version)
|
||||
}
|
||||
if frame.ActionKey != "task.run" {
|
||||
t.Errorf("action_key: want task.run got %s", frame.ActionKey)
|
||||
}
|
||||
if frame.LegacyIntent != IntentTriggerTask {
|
||||
t.Errorf("legacy_intent: want TRIGGER_TASK got %s", frame.LegacyIntent)
|
||||
}
|
||||
if frame.Goal != GoalGenerate {
|
||||
t.Errorf("goal: want generate got %s", frame.Goal)
|
||||
}
|
||||
if LegacyIntentFromFrame(frame) != IntentTriggerTask {
|
||||
t.Errorf("LegacyIntentFromFrame round-trip failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognize_DualWriteIntentFrameV2(t *testing.T) {
|
||||
rec := NewRecognizer()
|
||||
ctx := &ContextSnapshot{CurrentStage: string(bizdev.StageV01_Triage)}
|
||||
res, err := rec.Recognize(context.Background(), "现在这个便携超声立项评估到哪一步了?", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize: %v", err)
|
||||
}
|
||||
if res.IntentFrameV2 == nil {
|
||||
t.Fatal("expected intent_frame_v2 dual-write")
|
||||
}
|
||||
if res.IntentFrameV2.LegacyIntent != res.TopIntent.Intent {
|
||||
t.Errorf("v2 legacy %s != top %s", res.IntentFrameV2.LegacyIntent, res.TopIntent.Intent)
|
||||
}
|
||||
if res.IntentFrameV2.ActionKey == "" {
|
||||
t.Error("expected action_key on v2 frame")
|
||||
}
|
||||
if res.RankedFrames == nil || len(res.RankedFrames.Candidates) == 0 {
|
||||
t.Error("expected ranked_frames candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIntentFrameV2_ExpectationHitRelation(t *testing.T) {
|
||||
res := &IntentRecognitionResult{
|
||||
RawInput: "好",
|
||||
Branch: BranchExpectationHit,
|
||||
TopIntent: &CandidateIntent{
|
||||
Intent: IntentAdvanceStage,
|
||||
Confidence: 1.0,
|
||||
FilledSlots: map[string]string{
|
||||
SlotTargetStage: string(bizdev.StageV05_Feasibility),
|
||||
},
|
||||
},
|
||||
}
|
||||
frame := BuildIntentFrameV2(res, DefaultRegistry())
|
||||
if frame.Relation != RelationExpectationHit {
|
||||
t.Errorf("relation: want expectation_hit got %s", frame.Relation)
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,12 @@ func enrichShadowFields(res *IntentRecognitionResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeRecognitionResult 填充 shadow + V2 双写(不改变 TopIntent 路由语义)
|
||||
func finalizeRecognitionResult(res *IntentRecognitionResult) {
|
||||
enrichShadowFields(res)
|
||||
AttachIntentFrameV2(res)
|
||||
}
|
||||
|
||||
func isExplicitConfirmPhrase(input string) bool {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
phrases := []string{
|
||||
|
||||
@@ -180,6 +180,27 @@ func TestIntentGoldenV1_Phase1(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntentGoldenV1_V2DualWrite 每条识别结果应双写 intent_frame_v2
|
||||
func TestIntentGoldenV1_V2DualWrite(t *testing.T) {
|
||||
gf := loadGoldenV1(t)
|
||||
rec := intent.NewRecognizer()
|
||||
for _, c := range gf.Cases {
|
||||
t.Run(c.ID, func(t *testing.T) {
|
||||
ctx := buildContextFromGolden(t, c.Snapshot, c.Pending)
|
||||
res, err := rec.Recognize(context.Background(), c.Input, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recognize failed: %v", err)
|
||||
}
|
||||
if res.IntentFrameV2 == nil {
|
||||
t.Fatal("expected intent_frame_v2")
|
||||
}
|
||||
if res.IntentFrameV2.LegacyIntent != res.TopIntent.Intent {
|
||||
t.Errorf("v2 legacy mismatch: %s vs %s", res.IntentFrameV2.LegacyIntent, res.TopIntent.Intent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntentGoldenV1_ShadowFields shadow 观测字段应被填充
|
||||
func TestIntentGoldenV1_ShadowFields(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewRecognizer() Recognizer {
|
||||
func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userCtx *ContextSnapshot) (*IntentRecognitionResult, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return &IntentRecognitionResult{
|
||||
result := &IntentRecognitionResult{
|
||||
RawInput: input,
|
||||
Branch: BranchAmbiguousFallback,
|
||||
TopIntent: &CandidateIntent{
|
||||
@@ -37,7 +37,9 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
Candidates: []CandidateIntent{
|
||||
{Intent: IntentCasualChat, Branch: BranchAmbiguousFallback, Confidence: 1.0, Reasoning: "空输入"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
snapID := ""
|
||||
@@ -65,14 +67,16 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
FilledSlots: map[string]string{SlotForbiddenTarget: "identity_or_settings"},
|
||||
Reasoning: "检测到试图修改系统底层受控配置或用户法定身份/职位/工号,根据医疗器械质量与合规管理规范(ISO 13485 / 21 CFR Part 11),触发安全红线拦截",
|
||||
}
|
||||
return &IntentRecognitionResult{
|
||||
result := &IntentRecognitionResult{
|
||||
RawInput: trimmed,
|
||||
Branch: BranchNewIntentPreempt,
|
||||
TopIntent: top,
|
||||
Candidates: []CandidateIntent{*top},
|
||||
ContextSnapshotID: snapID,
|
||||
RequiresClarifying: false,
|
||||
}, nil
|
||||
}
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 1.5 否定写 + 只读查询:不得误触安全拦截,也不得当作写操作
|
||||
@@ -93,7 +97,7 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
ContextSnapshotID: snapID,
|
||||
RequiresClarifying: true,
|
||||
}
|
||||
enrichShadowFields(result)
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -107,14 +111,16 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
Reasoning: "纯UI视窗或主题控制指令,系统暂不支持通过对话直接操作界面视窗布局或主题,请通过界面按钮或快捷键操作",
|
||||
ClarificationQuestion: "检测到您下达了界面视窗或主题控制指令(如折叠/打开侧边栏、切换主题/深色浅色)。该操作属于前端UI视窗布局与主题控制,请直接点击界面右上角或侧边的视窗/主题切换按钮。",
|
||||
}
|
||||
return &IntentRecognitionResult{
|
||||
result := &IntentRecognitionResult{
|
||||
RawInput: trimmed,
|
||||
Branch: BranchNewIntentPreempt,
|
||||
TopIntent: top,
|
||||
Candidates: []CandidateIntent{*top},
|
||||
ContextSnapshotID: snapID,
|
||||
RequiresClarifying: false,
|
||||
}, nil
|
||||
}
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
@@ -137,7 +143,7 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
Candidates: []CandidateIntent{*hitCand},
|
||||
ContextSnapshotID: snapID,
|
||||
}
|
||||
enrichShadowFields(result)
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
@@ -253,7 +259,7 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
|
||||
ContextSnapshotID: snapID,
|
||||
RequiresClarifying: requiresClarifying,
|
||||
}
|
||||
enrichShadowFields(result)
|
||||
finalizeRecognitionResult(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -917,7 +923,7 @@ func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectati
|
||||
ClarificationQuestion: clarifyQ,
|
||||
}
|
||||
|
||||
return &IntentRecognitionResult{
|
||||
result := &IntentRecognitionResult{
|
||||
RawInput: input,
|
||||
Branch: BranchAmbiguousFallback,
|
||||
TopIntent: top,
|
||||
@@ -925,6 +931,8 @@ func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectati
|
||||
ContextSnapshotID: snapID,
|
||||
RequiresClarifying: true,
|
||||
}
|
||||
finalizeRecognitionResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package intent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RiskLevel 动作风险等级
|
||||
type RiskLevel string
|
||||
|
||||
const (
|
||||
RiskLow RiskLevel = "low"
|
||||
RiskMedium RiskLevel = "medium"
|
||||
RiskHigh RiskLevel = "high"
|
||||
)
|
||||
|
||||
// ActionAlias 自然语言别名
|
||||
type ActionAlias struct {
|
||||
Phrase string `json:"phrase"`
|
||||
Acts []DialogueAct `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// SlotSpec 槽位规格
|
||||
type SlotSpec struct {
|
||||
Key string `json:"key"`
|
||||
Required bool `json:"required"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// ActionSpec 可识别、可授权、可执行的动作单一事实源
|
||||
type ActionSpec struct {
|
||||
Key string `json:"key"`
|
||||
Domain string `json:"domain"`
|
||||
Object string `json:"object"`
|
||||
Goal GoalType `json:"goal"`
|
||||
Risk RiskLevel `json:"risk"`
|
||||
|
||||
Aliases []ActionAlias `json:"aliases,omitempty"`
|
||||
DialogueActs []DialogueAct `json:"dialogue_acts,omitempty"`
|
||||
|
||||
RequiredSlots []SlotSpec `json:"required_slots,omitempty"`
|
||||
OptionalSlots []SlotSpec `json:"optional_slots,omitempty"`
|
||||
|
||||
CapabilityKey string `json:"capability_key,omitempty"`
|
||||
AllowedStates []string `json:"allowed_states,omitempty"`
|
||||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||||
IdempotencyMode string `json:"idempotency_mode,omitempty"`
|
||||
|
||||
LegacyIntent IntentType `json:"legacy_intent"`
|
||||
}
|
||||
|
||||
// ActionRegistry 动作注册表
|
||||
type ActionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
byKey map[string]*ActionSpec
|
||||
byLegacy map[IntentType]*ActionSpec
|
||||
}
|
||||
|
||||
var defaultRegistry *ActionRegistry
|
||||
|
||||
func init() {
|
||||
defaultRegistry = NewActionRegistry()
|
||||
registerBuiltinActions(defaultRegistry)
|
||||
}
|
||||
|
||||
// DefaultRegistry 返回全局内置注册表
|
||||
func DefaultRegistry() *ActionRegistry {
|
||||
return defaultRegistry
|
||||
}
|
||||
|
||||
// NewActionRegistry 创建空注册表
|
||||
func NewActionRegistry() *ActionRegistry {
|
||||
return &ActionRegistry{
|
||||
byKey: make(map[string]*ActionSpec),
|
||||
byLegacy: make(map[IntentType]*ActionSpec),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册动作规格
|
||||
func (r *ActionRegistry) Register(spec ActionSpec) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if spec.Key == "" {
|
||||
return fmt.Errorf("action spec key is required")
|
||||
}
|
||||
if spec.LegacyIntent == "" {
|
||||
return fmt.Errorf("action spec %s: legacy_intent is required", spec.Key)
|
||||
}
|
||||
if existing, ok := r.byKey[spec.Key]; ok && existing.LegacyIntent != spec.LegacyIntent {
|
||||
return fmt.Errorf("duplicate action key %s", spec.Key)
|
||||
}
|
||||
cp := spec
|
||||
r.byKey[spec.Key] = &cp
|
||||
r.byLegacy[spec.LegacyIntent] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 按 action_key 查找
|
||||
func (r *ActionRegistry) Get(key string) (*ActionSpec, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
spec, ok := r.byKey[key]
|
||||
return spec, ok
|
||||
}
|
||||
|
||||
// GetByLegacyIntent 按旧 IntentType 查找
|
||||
func (r *ActionRegistry) GetByLegacyIntent(t IntentType) (*ActionSpec, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
spec, ok := r.byLegacy[t]
|
||||
return spec, ok
|
||||
}
|
||||
|
||||
// All 返回全部已注册动作
|
||||
func (r *ActionRegistry) All() []*ActionSpec {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]*ActionSpec, 0, len(r.byKey))
|
||||
for _, s := range r.byKey {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CapabilityKeys 派生能力键列表(供 AvailableActions 同源生成)
|
||||
func (r *ActionRegistry) CapabilityKeys() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
seen := make(map[string]struct{})
|
||||
var keys []string
|
||||
for _, s := range r.byKey {
|
||||
if s.CapabilityKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s.CapabilityKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s.CapabilityKey] = struct{}{}
|
||||
keys = append(keys, s.CapabilityKey)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func registerBuiltinActions(r *ActionRegistry) {
|
||||
specs := []ActionSpec{
|
||||
{Key: "project.advisory", Domain: "project", Object: "project", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryProgress, CapabilityKey: "QUERY_PROGRESS", DialogueActs: []DialogueAct{DialogueActAsk}},
|
||||
{Key: "project.fact.supplement", Domain: "project", Object: "project_fact", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentSupplementInfo, CapabilityKey: "SUPPLEMENT_INFO", DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "project.stage.advance", Domain: "project", Object: "project", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentAdvanceStage, CapabilityKey: "ADVANCE_STAGE", RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
|
||||
{Key: "task.run", Domain: "project", Object: "task", Goal: GoalGenerate, Risk: RiskMedium, LegacyIntent: IntentTriggerTask, CapabilityKey: "TRIGGER_TASK", DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "task.run.adhoc", Domain: "project", Object: "task", Goal: GoalGenerate, Risk: RiskMedium, LegacyIntent: IntentTriggerAdhocTask, CapabilityKey: "TRIGGER_ADHOC_TASK"},
|
||||
{Key: "conversation.chat", Domain: "conversation", Object: "system_ui", Goal: GoalConverse, Risk: RiskLow, LegacyIntent: IntentCasualChat},
|
||||
{Key: "conversation.decline", Domain: "conversation", Object: "system_ui", Goal: GoalDecide, Risk: RiskLow, LegacyIntent: IntentDeclineExpectation, DialogueActs: []DialogueAct{DialogueActDecline}},
|
||||
|
||||
{Key: "document.claim", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovClaim, CapabilityKey: "DOCGOV_CLAIM", DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "document.review.submit", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskMedium, LegacyIntent: IntentDocGovSubmitReview, CapabilityKey: "DOCGOV_SUBMIT_REVIEW", DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "document.review.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReviewApprove, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
|
||||
{Key: "document.review.reject", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovReviewReject, RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotRejectReason, Required: true, Label: "驳回理由"}}, DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "document.final.approve", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalApprove, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand, DialogueActConfirm}},
|
||||
{Key: "document.final.reject", Domain: "document_gov", Object: "controlled_document", Goal: GoalMutate, Risk: RiskHigh, LegacyIntent: IntentDocGovFinalReject, RequiresConfirm: true, RequiredSlots: []SlotSpec{{Key: SlotRejectReason, Required: true, Label: "驳回理由"}}, DialogueActs: []DialogueAct{DialogueActCommand}},
|
||||
{Key: "document.proposal.decide", Domain: "document_gov", Object: "controlled_document", Goal: GoalDecide, Risk: RiskMedium, LegacyIntent: IntentDocGovProposalDecide, DialogueActs: []DialogueAct{DialogueActConfirm, DialogueActDecline}},
|
||||
|
||||
{Key: "regulation.query", Domain: "knowledge", Object: "regulation", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryRegulations, CapabilityKey: "QUERY_REGULATIONS", DialogueActs: []DialogueAct{DialogueActAsk}},
|
||||
{Key: "case.query", Domain: "knowledge", Object: "artifact", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQuerySimilarCases, CapabilityKey: "QUERY_SIMILAR_CASES", DialogueActs: []DialogueAct{DialogueActAsk}},
|
||||
{Key: "permission.query", Domain: "project", Object: "user_permission", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryUserRights, CapabilityKey: "QUERY_USER_RIGHTS", DialogueActs: []DialogueAct{DialogueActAsk}},
|
||||
{Key: "document.reject_reason.query", Domain: "document_gov", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryRejectReason, DialogueActs: []DialogueAct{DialogueActAsk}},
|
||||
|
||||
{Key: "card.confirm", Domain: "conversation", Object: "system_ui", Goal: GoalDecide, Risk: RiskHigh, LegacyIntent: IntentConfirmAction, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActConfirm}},
|
||||
{Key: "project.stage.execute", Domain: "project", Object: "project", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentExecuteStageAdvance, RequiresConfirm: true},
|
||||
{Key: "security.forbidden", Domain: "security", Object: "system_configuration", Goal: GoalConverse, Risk: RiskHigh, LegacyIntent: IntentSecurityForbidden},
|
||||
}
|
||||
for _, s := range specs {
|
||||
_ = r.Register(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package intent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
legacyIntents := []IntentType{
|
||||
IntentQueryProgress, IntentSupplementInfo, IntentAdvanceStage, IntentTriggerTask,
|
||||
IntentTriggerAdhocTask, IntentCasualChat, IntentDeclineExpectation,
|
||||
IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove,
|
||||
IntentDocGovReviewReject, IntentDocGovFinalApprove, IntentDocGovFinalReject,
|
||||
IntentDocGovProposalDecide,
|
||||
IntentQueryRegulations, IntentQuerySimilarCases, IntentQueryUserRights, IntentQueryRejectReason,
|
||||
IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden,
|
||||
}
|
||||
for _, lt := range legacyIntents {
|
||||
spec, ok := r.GetByLegacyIntent(lt)
|
||||
if !ok {
|
||||
t.Errorf("missing registry entry for legacy intent %s", lt)
|
||||
continue
|
||||
}
|
||||
if spec.Key == "" || spec.LegacyIntent != lt {
|
||||
t.Errorf("invalid spec for %s: key=%s legacy=%s", lt, spec.Key, spec.LegacyIntent)
|
||||
}
|
||||
}
|
||||
if len(r.All()) < len(legacyIntents) {
|
||||
t.Errorf("expected at least %d specs, got %d", len(legacyIntents), len(r.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_RegisterDuplicateKey(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
s1 := ActionSpec{Key: "test.action", Domain: "test", Object: "test", Goal: GoalQuery, LegacyIntent: IntentCasualChat}
|
||||
s2 := ActionSpec{Key: "test.action", Domain: "test", Object: "test", Goal: GoalMutate, LegacyIntent: IntentSupplementInfo}
|
||||
if err := r.Register(s1); err != nil {
|
||||
t.Fatalf("register s1: %v", err)
|
||||
}
|
||||
if err := r.Register(s2); err == nil {
|
||||
t.Fatal("expected duplicate key error")
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,10 @@ type IntentRecognitionResult struct {
|
||||
ShadowMargin float64 `json:"shadow_margin,omitempty"`
|
||||
ShadowDialogueAct string `json:"shadow_dialogue_act,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 常见槽位 Key 常量定义
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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"`
|
||||
|
||||
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"`
|
||||
|
||||
LegacyIntent IntentType `json:"legacy_intent,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
}
|
||||
|
||||
// GoalType 用户目标分类(相对稳定)
|
||||
type GoalType string
|
||||
|
||||
const (
|
||||
GoalQuery GoalType = "query"
|
||||
GoalGenerate GoalType = "generate"
|
||||
GoalMutate GoalType = "mutate"
|
||||
GoalControl GoalType = "control"
|
||||
GoalConverse GoalType = "converse"
|
||||
GoalDecide GoalType = "decide"
|
||||
)
|
||||
|
||||
// ExpectationRelation 与 PendingExpectation 的关系(替代把状态塞进 ResolutionBranch)
|
||||
type ExpectationRelation string
|
||||
|
||||
const (
|
||||
RelationNew ExpectationRelation = "new"
|
||||
RelationExpectationHit ExpectationRelation = "expectation_hit"
|
||||
RelationExpectationDecline ExpectationRelation = "expectation_decline"
|
||||
RelationExpectationPatch ExpectationRelation = "expectation_patch"
|
||||
RelationExpectationPlusNew ExpectationRelation = "expectation_plus_new"
|
||||
RelationUnresolvedReference ExpectationRelation = "unresolved_reference"
|
||||
)
|
||||
|
||||
// IntentEvidence 规则命中证据(非统计概率)
|
||||
type IntentEvidence struct {
|
||||
Kind string `json:"kind"`
|
||||
Detail string `json:"detail"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
// SlotValue V2 槽位值
|
||||
type SlotValue struct {
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source,omitempty"` // rule | context | patch
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// RankedIntentSet 排序后的候选语义帧集合(供 L2 冲突裁决)
|
||||
type RankedIntentSet struct {
|
||||
Candidates []IntentFrameV2 `json:"candidates"`
|
||||
TopMargin float64 `json:"top_margin"`
|
||||
Conflicts []CandidateConflict `json:"conflicts,omitempty"`
|
||||
}
|
||||
|
||||
// CandidateConflict 候选冲突描述
|
||||
type CandidateConflict struct {
|
||||
Kind string `json:"kind"`
|
||||
TopAction string `json:"top_action"`
|
||||
SecondAction string `json:"second_action"`
|
||||
Margin float64 `json:"margin"`
|
||||
}
|
||||
|
||||
const IntentFrameV2Version = "v2.0"
|
||||
Reference in New Issue
Block a user