Why: F09 - invalid slot information was dropped at the gate, the router saw only Start, and the command entrance passed untyped text, so invalid-slot recognitions could still reach business execution paths. Evidence: intentgate/route/server tests pass (verify-u6.json); admission regression cases added. Impact: invalid or missing slots never start executable actions; clarification flows unchanged; cmdbus confirmation untouched. Refs: feature/agent-stack-audit-remediation-b1 U6
207 lines
6.8 KiB
Go
207 lines
6.8 KiB
Go
package server
|
||
|
||
// 意图接纳(F09):把「一次识别」落到「一次命令派发」之前,
|
||
// 按命令 DECLARED schema 完成参数映射与准入判定:
|
||
// - 只映射命令声明过的槽位键,并按声明类型转换(string/number/integer/boolean);
|
||
// 未声明的键一律丢弃——不把任意字符串复制进 params。
|
||
// - 声明的必填参数无法由「原文 + 已识别槽位」满足时不派发:不把拒绝留给
|
||
// 总线层的 schema 校验(那会变成一条让人困惑的 "params: missing required
|
||
// param x"),而是转成澄清诉求,与路由层的槽位缺口同一出口。
|
||
//
|
||
// 确认语义(高危命令的确认卡)完全由 cmdbus 收口,本文件不参与。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"agent-system-backend/internal/cmdbus"
|
||
"agent-system-backend/internal/intentgate"
|
||
"agent-system-backend/internal/route"
|
||
)
|
||
|
||
// chatTextParam 是 chat 入口恒定提供的原始文本参数名(既有文本处理器契约)。
|
||
const chatTextParam = "text"
|
||
|
||
// intentAdmission 是一次「意图命中 → 命令派发」的接纳结果。
|
||
type intentAdmission struct {
|
||
// Params 是派发参数:恒含 text,其余按声明 schema 映射。
|
||
Params map[string]any
|
||
// Blocked 表示准入未过(业务动作不得派发)。
|
||
Blocked bool
|
||
// Reason 是准入未过的原因(诊断用;面向用户的说法走澄清诉求)。
|
||
Reason string
|
||
// MissingParams 是声明要求但槽位无法满足的参数名(按声明序)。
|
||
MissingParams []string
|
||
}
|
||
|
||
// admitIntentCommand 按命令声明的 schema 把已识别槽位映射成派发参数。
|
||
// 声明不可解析(命令不存在/未启用)时按 fail-closed 返回 Blocked。
|
||
func admitIntentCommand(bus *cmdbus.Bus, commandID string, slots map[string]string, text string) intentAdmission {
|
||
if bus == nil {
|
||
return intentAdmission{Blocked: true, Reason: "命令总线不可用:" + commandID}
|
||
}
|
||
spec, _, err := bus.Registry().Resolve(commandID)
|
||
if err != nil {
|
||
return intentAdmission{Blocked: true, Reason: "命令不可解析:" + commandID}
|
||
}
|
||
params, missing := mapSchemaParams(spec.ParamsSchema, slots)
|
||
params[chatTextParam] = text
|
||
if len(missing) > 0 {
|
||
return intentAdmission{Params: params, Blocked: true,
|
||
Reason: "命令必填参数未满足:" + strings.Join(missing, "、"),
|
||
MissingParams: missing}
|
||
}
|
||
return intentAdmission{Params: params}
|
||
}
|
||
|
||
// mapSchemaParams 把槽位按 JSON Schema(draft-07 子集,与总线侧校验同款)
|
||
// 投影成参数。只认声明过的键,并按声明类型转换;转换不成的不写参数
|
||
// (数组/对象等非标量声明也不会收到字符串)。必填参数里无法满足的按键名返回。
|
||
func mapSchemaParams(schema json.RawMessage, slots map[string]string) (map[string]any, []string) {
|
||
params := map[string]any{}
|
||
props, required, ok := objectSchema(schema)
|
||
if !ok {
|
||
// 没有可用声明:不复制任何槽位(fail-closed),只保留原文入口参数。
|
||
return params, nil
|
||
}
|
||
keys := make([]string, 0, len(slots))
|
||
for k := range slots {
|
||
if _, declared := props[k]; declared {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
if v, ok := castParam(props[k], slots[k]); ok {
|
||
params[k] = v
|
||
}
|
||
}
|
||
var missing []string
|
||
for _, k := range required {
|
||
if k == chatTextParam {
|
||
continue // 原文入口恒供
|
||
}
|
||
if _, ok := params[k]; !ok {
|
||
missing = append(missing, k)
|
||
}
|
||
}
|
||
return params, missing
|
||
}
|
||
|
||
// objectSchema 解析命令的 paramsSchema;非对象/非法 schema 返回 ok=false。
|
||
func objectSchema(schema json.RawMessage) (props map[string]any, required []string, ok bool) {
|
||
if len(schema) == 0 {
|
||
return nil, nil, false
|
||
}
|
||
var sc struct {
|
||
Type string `json:"type"`
|
||
Required []string `json:"required"`
|
||
Properties map[string]any `json:"properties"`
|
||
}
|
||
if err := json.Unmarshal(schema, &sc); err != nil {
|
||
return nil, nil, false
|
||
}
|
||
if sc.Type != "" && sc.Type != "object" {
|
||
return nil, nil, false
|
||
}
|
||
if sc.Properties == nil {
|
||
sc.Properties = map[string]any{}
|
||
}
|
||
return sc.Properties, sc.Required, true
|
||
}
|
||
|
||
// castParam 按声明类型把槽位字符串转成参数值;类型不支持或转换失败返回 false。
|
||
func castParam(decl any, raw string) (any, bool) {
|
||
m, _ := decl.(map[string]any)
|
||
want, _ := m["type"].(string)
|
||
switch want {
|
||
case "", "string":
|
||
return raw, true
|
||
case "number":
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return f, true
|
||
case "integer":
|
||
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return n, true
|
||
case "boolean":
|
||
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return b, true
|
||
default:
|
||
// array/object 等非标量声明:拒绝把字符串塞进去。
|
||
return nil, false
|
||
}
|
||
}
|
||
|
||
// clarifyPayload 是回给调用方的澄清诉求(postMessage 响应里的 clarify 字段)。
|
||
// 既有端点上的附加字段,不新增 API 面:缺失与非法各自带 key/原因/建议提问。
|
||
type clarifyPayload struct {
|
||
Intent string `json:"intent,omitempty"`
|
||
Command string `json:"command,omitempty"`
|
||
Question string `json:"question,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
Missing []intentgate.SlotQuestion `json:"missing,omitempty"`
|
||
Invalid []intentgate.SlotDefect `json:"invalid,omitempty"`
|
||
}
|
||
|
||
// newClarifyPayload 把路由裁决与准入结果合成一条澄清诉求;无需澄清时返回 nil。
|
||
func newClarifyPayload(res intentgate.MatchResult, dec route.Decision, adm *intentAdmission) *clarifyPayload {
|
||
if !dec.Clarification.Needed && (adm == nil || !adm.Blocked) {
|
||
return nil
|
||
}
|
||
c := dec.Clarification
|
||
out := &clarifyPayload{
|
||
Intent: res.ID,
|
||
Command: firstNonEmpty(c.Command, dec.CommandID),
|
||
Question: strings.TrimSpace(c.Question),
|
||
Missing: append([]intentgate.SlotQuestion(nil), c.Missing...),
|
||
Invalid: append([]intentgate.SlotDefect(nil), c.Invalid...),
|
||
}
|
||
if adm != nil && adm.Blocked {
|
||
out.Reason = adm.Reason
|
||
names := make([]string, 0, len(adm.MissingParams))
|
||
for _, name := range adm.MissingParams {
|
||
if slotQuestionKnown(out.Missing, name) {
|
||
continue
|
||
}
|
||
out.Missing = append(out.Missing, intentgate.SlotQuestion{Key: name, Question: name})
|
||
names = append(names, name)
|
||
}
|
||
if out.Question == "" && len(names) > 0 {
|
||
out.Question = "请补充:" + strings.Join(names, "、")
|
||
}
|
||
}
|
||
if out.Question == "" {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func slotQuestionKnown(list []intentgate.SlotQuestion, key string) bool {
|
||
for _, q := range list {
|
||
if q.Key == key {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func firstNonEmpty(vals ...string) string {
|
||
for _, v := range vals {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|