diff --git a/internal/intent/compatibility.go b/internal/intent/compatibility.go new file mode 100644 index 00000000..756e2e5d --- /dev/null +++ b/internal/intent/compatibility.go @@ -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()) +} diff --git a/internal/intent/compatibility_test.go b/internal/intent/compatibility_test.go new file mode 100644 index 00000000..150f1c4a --- /dev/null +++ b/internal/intent/compatibility_test.go @@ -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) + } +} diff --git a/internal/intent/dialogue_act.go b/internal/intent/dialogue_act.go index a608046b..14b64bf0 100644 --- a/internal/intent/dialogue_act.go +++ b/internal/intent/dialogue_act.go @@ -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{ diff --git a/internal/intent/hard_negative_test.go b/internal/intent/hard_negative_test.go index 7d8436e2..9b0d0fa0 100644 --- a/internal/intent/hard_negative_test.go +++ b/internal/intent/hard_negative_test.go @@ -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() diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 510f2ae4..2e627051 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -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 从文本中提取结构化事实槽位 (支持补丁合并与补充信息) diff --git a/internal/intent/registry.go b/internal/intent/registry.go new file mode 100644 index 00000000..9e48854b --- /dev/null +++ b/internal/intent/registry.go @@ -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) + } +} diff --git a/internal/intent/registry_test.go b/internal/intent/registry_test.go new file mode 100644 index 00000000..1f919d2a --- /dev/null +++ b/internal/intent/registry_test.go @@ -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") + } +} diff --git a/internal/intent/types.go b/internal/intent/types.go index 7c3c0586..92becb6e 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -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 常量定义 diff --git a/internal/intent/types_v2.go b/internal/intent/types_v2.go new file mode 100644 index 00000000..4f29fedf --- /dev/null +++ b/internal/intent/types_v2.go @@ -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"