215 lines
9.1 KiB
Go
215 lines
9.1 KiB
Go
package intent
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// 风险等级。透传给宿主做授权与确认判断,本模块不解释。
|
||
const (
|
||
RiskLow = "low"
|
||
RiskMedium = "medium"
|
||
RiskHigh = "high"
|
||
)
|
||
|
||
// ConfidenceSpec 各字段为零值时使用包默认值。
|
||
const (
|
||
DefaultBase = 0.7
|
||
DefaultPatternBonus = 0.2
|
||
DefaultThreshold = 0.6
|
||
)
|
||
|
||
// MatchSpec 声明式匹配规则。
|
||
// 计分:score = base + (任一 pattern 命中 ? pattern_bonus : 0),上限 1.0。
|
||
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
|
||
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
|
||
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
|
||
type MatchSpec struct {
|
||
IncludesAny []string `json:"includes_any,omitempty"`
|
||
IncludesAll []string `json:"includes_all,omitempty"`
|
||
ExcludesAny []string `json:"excludes_any,omitempty"`
|
||
Patterns []string `json:"patterns,omitempty"`
|
||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||
}
|
||
|
||
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
|
||
// label/question 供宿主做 HITL 提问与目录展示;enum/format/normalize/validator
|
||
// 构成确定性校验链:抽取 → 归一化 → 校验,校验失败进 Candidate.InvalidSlots。
|
||
type SlotSpec struct {
|
||
Key string `json:"key"`
|
||
Required bool `json:"required,omitempty"`
|
||
Pattern string `json:"pattern"`
|
||
Group int `json:"group,omitempty"`
|
||
// Label 槽位的人类可读名(目录/HITL 展示用)。
|
||
Label string `json:"label,omitempty"`
|
||
// Question 建议的澄清提问(HITL 生成用),如"要查询哪个运行?请提供 run 编号"。
|
||
Question string `json:"question,omitempty"`
|
||
// Enum 合法值枚举(归一化后校验)。
|
||
Enum []string `json:"enum,omitempty"`
|
||
// Format 整段匹配正则(校验,区别于 Pattern 的抽取用途)。
|
||
Format string `json:"format,omitempty"`
|
||
// Normalize 归一化步骤,按序应用:trim | lower | upper。
|
||
Normalize []string `json:"normalize,omitempty"`
|
||
// Validator 宿主经 RegisterSlotValidator 注册的校验器名。
|
||
Validator string `json:"validator,omitempty"`
|
||
}
|
||
|
||
// ConfidenceSpec 置信度参数。Base 初始分;PatternBonus 模式命中加成;
|
||
// Threshold 为该 spec 自身的过阈线,score ≥ threshold 才进入候选。
|
||
type ConfidenceSpec struct {
|
||
Base float64 `json:"base,omitempty"`
|
||
PatternBonus float64 `json:"pattern_bonus,omitempty"`
|
||
Threshold float64 `json:"threshold,omitempty"`
|
||
}
|
||
|
||
// IntentSpec 意图定义,Go 注册与 JSON overlay 共用同一结构。
|
||
// Handler 与 Metadata 由宿主解释(路由通道、动作键等),本模块只透传。
|
||
type IntentSpec struct {
|
||
ID string `json:"id"`
|
||
Domain string `json:"domain"`
|
||
Version int `json:"version"`
|
||
Description string `json:"description,omitempty"`
|
||
Priority int `json:"priority,omitempty"`
|
||
Disabled bool `json:"disabled,omitempty"`
|
||
Matcher MatchSpec `json:"matcher"`
|
||
Slots []SlotSpec `json:"slots,omitempty"`
|
||
Confidence ConfidenceSpec `json:"confidence,omitempty"`
|
||
Risk string `json:"risk,omitempty"`
|
||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||
Handler string `json:"handler,omitempty"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
var (
|
||
domainRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
|
||
segmentRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
|
||
slotKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||
)
|
||
|
||
// limits 在 Options 中声明,防止配置膨胀拖垮识别与构建。
|
||
type limits struct {
|
||
maxSpecs int
|
||
maxPatternsPerSpec int
|
||
maxSlotsPerSpec int
|
||
}
|
||
|
||
// validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。
|
||
func validateSpec(spec IntentSpec, lim limits) error {
|
||
if spec.Domain == "" {
|
||
return newError(InvalidSpec, "domain 为空", "field", "domain")
|
||
}
|
||
if !domainRe.MatchString(spec.Domain) {
|
||
return newError(InvalidSpec, "domain 格式不合法(^[a-z][a-z0-9-]{1,31}$)", "field", "domain", "domain", spec.Domain)
|
||
}
|
||
if spec.ID == "" {
|
||
return newError(InvalidSpec, "id 为空", "field", "id")
|
||
}
|
||
// id 形如 <domain>.<mnemonic>,mnemonic 可继续分级(demo.run.status_query)。
|
||
segments := strings.Split(spec.ID, ".")
|
||
if len(segments) < 2 {
|
||
return newError(InvalidSpec, "id 至少包含 domain 与记忆名两段", "field", "id", "id", spec.ID)
|
||
}
|
||
if segments[0] != spec.Domain {
|
||
return newError(InvalidSpec, "id 首段必须等于 domain", "field", "id", "id", spec.ID, "domain", spec.Domain)
|
||
}
|
||
for _, seg := range segments[1:] {
|
||
if !segmentRe.MatchString(seg) {
|
||
return newError(InvalidSpec, "id 段格式不合法(^[a-z][a-z0-9_-]{0,63}$)", "field", "id", "id", spec.ID)
|
||
}
|
||
}
|
||
if spec.Version < 1 {
|
||
return newError(InvalidSpec, "version 必须 ≥ 1", "field", "version", "version", spec.Version)
|
||
}
|
||
switch spec.Risk {
|
||
case "", RiskLow, RiskMedium, RiskHigh:
|
||
default:
|
||
return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk)
|
||
}
|
||
|
||
m := spec.Matcher
|
||
for _, terms := range []struct {
|
||
name string
|
||
value []string
|
||
}{{"includes_any", m.IncludesAny}, {"includes_all", m.IncludesAll}, {"excludes_any", m.ExcludesAny}} {
|
||
for _, t := range terms.value {
|
||
if strings.TrimSpace(t) == "" {
|
||
return newError(InvalidSpec, "matcher 词条为空", "field", "matcher."+terms.name)
|
||
}
|
||
}
|
||
}
|
||
if len(m.IncludesAny) == 0 && len(m.IncludesAll) == 0 && len(m.Patterns) == 0 {
|
||
return newError(InvalidSpec, "matcher 缺少正向条件(includes_any / includes_all / patterns 至少一项)", "field", "matcher", "id", spec.ID)
|
||
}
|
||
if lim.maxPatternsPerSpec > 0 && len(m.Patterns) > lim.maxPatternsPerSpec {
|
||
return newError(InvalidSpec, "patterns 数量超过上限", "field", "matcher.patterns", "count", len(m.Patterns), "max", lim.maxPatternsPerSpec, "id", spec.ID)
|
||
}
|
||
for i, p := range m.Patterns {
|
||
if _, err := regexp.Compile(p); err != nil {
|
||
return newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "pattern", p, "id", spec.ID, "error", err.Error())
|
||
}
|
||
}
|
||
if lim.maxSlotsPerSpec > 0 && len(spec.Slots) > lim.maxSlotsPerSpec {
|
||
return newError(InvalidSpec, "slots 数量超过上限", "field", "slots", "count", len(spec.Slots), "max", lim.maxSlotsPerSpec, "id", spec.ID)
|
||
}
|
||
for i, s := range spec.Slots {
|
||
if !slotKeyRe.MatchString(s.Key) {
|
||
return newError(InvalidSpec, "slot key 格式不合法(^[a-z][a-z0-9_]{0,63}$)", "field", fmt.Sprintf("slots[%d].key", i), "key", s.Key)
|
||
}
|
||
re, err := regexp.Compile(s.Pattern)
|
||
if err != nil {
|
||
return newError(InvalidSpec, "slot pattern 编译失败", "field", fmt.Sprintf("slots[%d].pattern", i), "key", s.Key, "error", err.Error())
|
||
}
|
||
// group 约定:未填默认取 1(第一个捕获组);需整段匹配时在 pattern 里写捕获组即可。
|
||
g := effectiveGroup(s.Group)
|
||
if g < 1 || g > re.NumSubexp() {
|
||
return newError(InvalidSpec, "slot group 超出捕获组范围", "field", fmt.Sprintf("slots[%d].group", i), "key", s.Key, "group", s.Group, "groups", re.NumSubexp())
|
||
}
|
||
for _, e := range s.Enum {
|
||
if strings.TrimSpace(e) == "" {
|
||
return newError(InvalidSpec, "slot enum 含空值", "field", fmt.Sprintf("slots[%d].enum", i), "key", s.Key)
|
||
}
|
||
}
|
||
if s.Format != "" {
|
||
if _, err := regexp.Compile(s.Format); err != nil {
|
||
return newError(InvalidSpec, "slot format 编译失败", "field", fmt.Sprintf("slots[%d].format", i), "key", s.Key, "error", err.Error())
|
||
}
|
||
}
|
||
for _, op := range s.Normalize {
|
||
if _, ok := builtinNormalizers[op]; !ok {
|
||
return newError(InvalidSpec, "slot normalize 步骤不合法(trim|lower|upper)", "field", fmt.Sprintf("slots[%d].normalize", i), "key", s.Key, "op", op)
|
||
}
|
||
}
|
||
if s.Validator != "" {
|
||
if _, ok := LookupSlotValidator(s.Validator); !ok {
|
||
return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "先调用 intent.RegisterSlotValidator 再注册/加载 spec")
|
||
}
|
||
}
|
||
}
|
||
|
||
c := spec.Confidence
|
||
if c.Base < 0 || c.Base > 1 {
|
||
return newError(InvalidSpec, "confidence.base 须在 (0,1],0/留空取默认", "field", "confidence.base", "id", spec.ID)
|
||
}
|
||
if c.PatternBonus < 0 || c.PatternBonus >= 1 {
|
||
return newError(InvalidSpec, "confidence.pattern_bonus 须在 [0,1),留空取默认", "field", "confidence.pattern_bonus", "id", spec.ID)
|
||
}
|
||
if c.Threshold < 0 || c.Threshold > 1 {
|
||
return newError(InvalidSpec, "confidence.threshold 须在 (0,1],0/留空取默认", "field", "confidence.threshold", "id", spec.ID)
|
||
}
|
||
for k := range spec.Metadata {
|
||
if strings.TrimSpace(k) == "" {
|
||
return newError(InvalidSpec, "metadata 键为空", "field", "metadata", "id", spec.ID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// effectiveGroup 槽位 group 的缺省规则:未填(0)默认取 1。
|
||
func effectiveGroup(g int) int {
|
||
if g == 0 {
|
||
return 1
|
||
}
|
||
return g
|
||
}
|