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:
WeiCHEN
2026-09-11 11:50:56 +08:00
co-authored by Cursor
parent 7efe0ba5bd
commit cbe9f8c43a
9 changed files with 577 additions and 10 deletions
+150
View File
@@ -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())
}
+86
View File
@@ -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)
}
}
+6
View File
@@ -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{
+21
View File
@@ -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()
+18 -10
View File
@@ -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 从文本中提取结构化事实槽位 (支持补丁合并与补充信息)
+174
View File
@@ -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)
}
}
+41
View File
@@ -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")
}
}
+4
View File
@@ -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 常量定义
+77
View File
@@ -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"