package intentsem import ( "context" "encoding/json" "fmt" "strings" agentintent "git-ssh.u1680832.nyat.app/weichen/Agent-Intent.git/intent" agentllm "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/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() }