Files
weichen 0e55875329 Admit intents with full slot validity and schema-typed slots
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
2026-10-21 16:34:12 +00:00

207 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}