Why: I2 layering - between rules and the L2 classifier there is now a deterministic host-scored semantic layer (rules then semantic then model), and fallback can be rescued by a validated command generator instead of going straight to the default channel. Evidence: go vet clean; full suite ok, including ten new tests (rerank resolve/below-threshold/tie/error-then-L2, generator valid/invalid/error, catalog top-k). Impact: IntentSpec gains Examples; Options gains Semantic (with threshold), Generator; ambiguous candidates rerank through host scores before L2; fallback validates generated commands (action/slots/kind fail-closed) and synthesizes Top with slots; generated commands are no longer overwritten by deriveCommands; CatalogTopK by priority. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
146 lines
4.7 KiB
Go
146 lines
4.7 KiB
Go
package intent
|
||
|
||
import (
|
||
"sort"
|
||
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// CatalogSlot 目录中的槽位视图:给规划者/领导 Agent 看的声明式信息
|
||
// (有哪些能力、缺什么信息、建议怎么问),不暴露匹配实现细节。
|
||
type CatalogSlot struct {
|
||
Key string `json:"key"`
|
||
Required bool `json:"required,omitempty"`
|
||
Label string `json:"label,omitempty"`
|
||
Question string `json:"question,omitempty"`
|
||
Enum []string `json:"enum,omitempty"`
|
||
Format string `json:"format,omitempty"`
|
||
}
|
||
|
||
// CatalogSpec 目录中的单条能力。
|
||
type CatalogSpec struct {
|
||
ID string `json:"id"`
|
||
Domain string `json:"domain,omitempty"`
|
||
Version int `json:"version,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
Risk string `json:"risk,omitempty"`
|
||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||
Handler string `json:"handler,omitempty"`
|
||
Slots []CatalogSlot `json:"slots,omitempty"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
// Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。
|
||
// 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。
|
||
func (s *Snapshot) Catalog() []CatalogSpec {
|
||
out := make([]CatalogSpec, 0, len(s.specs))
|
||
for _, cs := range s.specs {
|
||
if cs.guard {
|
||
continue
|
||
}
|
||
view := CatalogSpec{
|
||
ID: cs.spec.ID,
|
||
Domain: cs.spec.Domain,
|
||
Version: cs.spec.Version,
|
||
Description: cs.spec.Description,
|
||
Risk: cs.spec.Risk,
|
||
RequiresConfirm: cs.spec.RequiresConfirm,
|
||
Handler: cs.spec.Handler,
|
||
Metadata: catalogMetadata(cs.spec.Metadata),
|
||
}
|
||
if view.Risk == "" {
|
||
view.Risk = RiskLow
|
||
}
|
||
for _, sl := range cs.slots {
|
||
view.Slots = append(view.Slots, CatalogSlot{
|
||
Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question,
|
||
Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key),
|
||
})
|
||
}
|
||
out = append(out, view)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CatalogMarkdown 渲染注入规划者/领导提示词用的目录文本(确定性)。
|
||
// 文案面向 LLM:说明可直接调用、必填槽位与提问建议。
|
||
func (s *Snapshot) CatalogMarkdown() string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "# 系统能力目录(意图快照 revision=%d)\n\n", s.rev)
|
||
b.WriteString("以下能力可直接调用,无需派智能体执行。子任务命中其一时,输出携带 intent_id 的步骤并填齐必填槽位;\n")
|
||
b.WriteString("必填槽位信息不足时不要编造,改为向用户澄清(优先使用各槽位的提问建议)。\n\n")
|
||
specs := s.Catalog()
|
||
if len(specs) == 0 {
|
||
b.WriteString("(目录为空:当前没有可直接调用的系统能力)\n")
|
||
return b.String()
|
||
}
|
||
for _, sp := range specs {
|
||
desc := sp.Description
|
||
if desc == "" {
|
||
desc = sp.ID
|
||
}
|
||
fmt.Fprintf(&b, "## %s — %s\n", sp.ID, desc)
|
||
confirm := "否"
|
||
if sp.RequiresConfirm {
|
||
confirm = "是"
|
||
}
|
||
fmt.Fprintf(&b, "- 风险:%s;执行前需人工确认:%s\n", sp.Risk, confirm)
|
||
for _, sl := range sp.Slots {
|
||
req := "选填"
|
||
if sl.Required {
|
||
req = "必填"
|
||
}
|
||
label := sl.Label
|
||
if label == "" {
|
||
label = sl.Key
|
||
}
|
||
fmt.Fprintf(&b, "- 槽位 %s(%s,%s)", sl.Key, req, label)
|
||
if len(sl.Enum) > 0 {
|
||
fmt.Fprintf(&b, ";取值:%s", strings.Join(sl.Enum, " | "))
|
||
}
|
||
if sl.Format != "" {
|
||
fmt.Fprintf(&b, ";格式:%s", sl.Format)
|
||
}
|
||
b.WriteString("\n")
|
||
if sl.Question != "" {
|
||
fmt.Fprintf(&b, " - 提问建议:%s\n", sl.Question)
|
||
}
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// specSlotFormat 从原始 spec 取槽位的 Format 声明(编译产物不保留字符串正则源)。
|
||
func specSlotFormat(spec IntentSpec, key string) string {
|
||
for _, sl := range spec.Slots {
|
||
if sl.Key == key {
|
||
return sl.Format
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序;
|
||
// 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。
|
||
// k <= 0 时等同 Catalog() 全量。
|
||
func (s *Snapshot) CatalogTopK(k int) []CatalogSpec {
|
||
all := s.Catalog()
|
||
if k <= 0 || len(all) <= k {
|
||
return all
|
||
}
|
||
// Priority 不在目录视图里;用 spec 表内的 priority 排序。
|
||
prio := map[string]int{}
|
||
for _, cs := range s.specs {
|
||
prio[cs.spec.ID] = cs.spec.Priority
|
||
}
|
||
sort.SliceStable(all, func(i, j int) bool {
|
||
if prio[all[i].ID] != prio[all[j].ID] {
|
||
return prio[all[i].ID] > prio[all[j].ID]
|
||
}
|
||
return all[i].ID < all[j].ID
|
||
})
|
||
return all[:k]
|
||
}
|