ci / vet-and-test (pull_request) Failing after 34s
Why: I2 increments 1-2 shipped the agent-intent ports (SemanticScorer, CommandGenerator); the backend still had no implementation, so the slow lane could not be enabled in the product. Evidence: go test ./... green; lint-boundaries ok; the two new runtime.intentSemantic / runtime.intentGenerator flags are registered with default false and the gate defaults to the I1 recognizer when both are off (TestIntentSlowLaneFlagsDefaultOff, TestGate*). Impact: no behavior change by default; enabling runtime.intentSemantic routes ambiguous rule matches through embedding cosine scoring, and runtime.intentGenerator lets the lightweight model propose commands on fallback with library-side fail-closed validation. Nested agent-intent pin moves to 343b1e6 (I2 increments 1-2). Refs: I2 increment 3
130 lines
4.6 KiB
Go
130 lines
4.6 KiB
Go
package intentsem
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
agentintent "agent-intent/intent"
|
||
agentllm "agent-native/runtime/llm"
|
||
)
|
||
|
||
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
|
||
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
|
||
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
|
||
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
|
||
// 出错时识别保持 fallback(fail-closed)。
|
||
type LightGenerator struct {
|
||
// Stream 提供方边界(注入点;测试可脚本化)。
|
||
Stream agentllm.StreamFunc
|
||
// Model 是轻量档模型名。
|
||
Model string
|
||
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
|
||
APIBase string
|
||
APIKey string
|
||
// MaxTokens 上限;0 = 1024。
|
||
MaxTokens int
|
||
}
|
||
|
||
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
|
||
type generatedCommand struct {
|
||
Kind string `json:"kind"`
|
||
Action generatedAction `json:"action,omitempty"`
|
||
Slots map[string]string `json:"slots,omitempty"`
|
||
}
|
||
|
||
type generatedAction struct {
|
||
Kind string `json:"kind"`
|
||
ID string `json:"id"`
|
||
}
|
||
|
||
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
|
||
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
|
||
return nil, nil
|
||
}
|
||
maxTok := g.MaxTokens
|
||
if maxTok <= 0 {
|
||
maxTok = 1024
|
||
}
|
||
msgs := []agentllm.Message{
|
||
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
|
||
{Role: agentllm.RoleUser, Content: input},
|
||
}
|
||
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
|
||
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
|
||
})
|
||
var sb strings.Builder
|
||
for ev := range ch {
|
||
switch ev.Kind {
|
||
case agentllm.KindTextDelta:
|
||
sb.WriteString(ev.Text)
|
||
case agentllm.KindError:
|
||
if ev.Err != nil {
|
||
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
|
||
}
|
||
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
|
||
}
|
||
}
|
||
return parseCommands(sb.String())
|
||
}
|
||
|
||
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
|
||
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
|
||
func parseCommands(raw string) ([]agentintent.Command, error) {
|
||
text := strings.TrimSpace(raw)
|
||
text = strings.TrimPrefix(text, "```json")
|
||
text = strings.TrimPrefix(text, "```")
|
||
text = strings.TrimSuffix(text, "```")
|
||
text = strings.TrimSpace(text)
|
||
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
|
||
if lo < 0 || hi <= lo {
|
||
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
|
||
}
|
||
var gen []generatedCommand
|
||
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
|
||
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
|
||
}
|
||
out := make([]agentintent.Command, 0, len(gen))
|
||
for _, c := range gen {
|
||
out = append(out, agentintent.Command{
|
||
Kind: agentintent.CommandKind(c.Kind),
|
||
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
|
||
Slots: c.Slots,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
|
||
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
|
||
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
|
||
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
|
||
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
|
||
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
|
||
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
|
||
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
|
||
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
|
||
for _, spec := range catalog {
|
||
sb.WriteString("- id=" + spec.ID)
|
||
if spec.Description != "" {
|
||
sb.WriteString(" 描述=" + spec.Description)
|
||
}
|
||
if spec.Handler != "" {
|
||
sb.WriteString(" handler=" + spec.Handler)
|
||
}
|
||
if len(spec.Slots) > 0 {
|
||
keys := make([]string, 0, len(spec.Slots))
|
||
for _, sl := range spec.Slots {
|
||
keys = append(keys, sl.Key)
|
||
}
|
||
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
return sb.String()
|
||
}
|