Refactored orchestrator for staged file handling, added structured prompt support, adjusted Feishu file handling

This commit is contained in:
whlaoding
2026-03-08 22:38:29 +08:00
parent e2f806edb3
commit 52b8dbb835
30 changed files with 9325 additions and 34 deletions

View File

@@ -3,6 +3,7 @@ package agent
import (
"context"
"fmt"
"runtime"
"sort"
"strconv"
"strings"
@@ -32,6 +33,29 @@ type Orchestrator struct {
enableCapabilityGap bool
log *logger.Logger
skillsMu sync.RWMutex
pendingFilesMu sync.Mutex
pendingFiles map[string][]pendingFileRef
}
type pendingFileRef struct {
ID string
Name string
MimeType string
}
type capabilityRoutingResult struct {
NeedSkills bool
SelectedToolNames []string
SelectedSkills []knowledge.Skill
Reason string
UsedFallback bool
}
type filePromptContext struct {
Summary string
FatalReason string
FileIDs []string
Uploaded []pendingFileRef
}
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
@@ -76,6 +100,7 @@ func NewOrchestrator(
reactMaxStep: reactMaxStep,
enableCapabilityGap: enableCapabilityGap,
log: log,
pendingFiles: make(map[string][]pendingFileRef),
}
}
@@ -85,12 +110,20 @@ func NewOrchestrator(
// - 是否需要调用工具action + action_input
// 循环持续进行,直到 LLM 返回 is_final_answer=true。
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
return o.handleMessageInternal(ctx, chatID, userID, text, nil)
}
func (o *Orchestrator) HandleMessageWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
return o.handleMessageInternal(ctx, chatID, userID, text, files)
}
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
// 为链路追踪设置唯一的 TraceID
traceID := logger.NewTraceID()
ctx = logger.WithTraceID(ctx, traceID)
traceLogPrefix := "trace_id=" + traceID
if o.log != nil {
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d", traceLogPrefix, chatID, userID, len(text))
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d files=%d", traceLogPrefix, chatID, userID, len(text), len(files))
o.log.Debugf("%s handle message text=%q", traceLogPrefix, text)
}
@@ -111,6 +144,38 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
return report, nil
}
trimmedText := strings.TrimSpace(text)
isFileOnly := len(files) > 0 && trimmedText == ""
if isFileOnly {
if err := o.store.SaveMessage(chatID, userID, "user", "[FILE_UPLOAD]"); err != nil {
if o.log != nil {
o.log.Errorf("%s save file-only user marker failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
uploadCtx := o.prepareFilePromptContext(ctx, files, nil)
if strings.TrimSpace(uploadCtx.FatalReason) != "" {
finalText := "文件上传失败,无法建立文档上下文。" + "\n" + uploadCtx.FatalReason
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
o.log.Warnf("%s save upload failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return finalText, nil
}
o.appendPendingFiles(chatID, userID, uploadCtx.toPendingRefs())
finalText := o.buildFileUploadAck(uploadCtx)
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil {
if o.log != nil {
o.log.Errorf("%s save file upload ack failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
if o.log != nil {
o.log.Infof("%s file-only message handled chat_id=%s cached_files=%d", traceLogPrefix, chatID, len(uploadCtx.FileIDs))
}
return finalText, nil
}
// 保存用户消息到 SQLite 中
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
if o.log != nil {
@@ -133,13 +198,30 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
}
// 进入统一 ReAct 循环
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text)
pendingRefs := o.getPendingFiles(chatID, userID)
fileCtx := o.prepareFilePromptContext(ctx, files, pendingRefs)
if strings.TrimSpace(fileCtx.FatalReason) != "" {
finalText := "文件上传失败,无法继续进行文档解析。" + "\n" + fileCtx.FatalReason
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
o.log.Warnf("%s save assistant failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
if o.log != nil {
o.log.Warnf("%s stop before react due to file upload failure reason=%s", traceLogPrefix, fileCtx.FatalReason)
}
return finalText, nil
}
routeInput := composeRouteInput(text, fileCtx.Summary)
route := o.routeCapabilities(ctx, routeInput)
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text, fileCtx, routeInput, route)
if err != nil {
if o.log != nil {
o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
if len(pendingRefs) > 0 {
o.clearPendingFiles(chatID, userID)
}
// 最终将机器人的回复也加入记忆缓存
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
@@ -156,21 +238,29 @@ func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text s
}
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
// 包含人格设定、所有可用技能(含完整内容)、所有可用工具、以及 JSON 输出格式约束
func (o *Orchestrator) buildUnifiedSystemPrompt() string {
// 工具始终可用技能仅按当前问题挑选相关项作为增强上下文
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, route capabilityRoutingResult) string {
skillMetaDoc := o.formatSkillSummariesForPrompt()
allSkillsDoc := o.formatAllSkillsContent()
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, route.SelectedSkills)
toolDoc := o.formatToolDoc()
runtimeDoc := formatRuntimeContextForPrompt()
routeDoc := formatRouteForPrompt(route)
return strings.Join([]string{
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
o.soul,
"",
"===== 运行环境 =====",
runtimeDoc,
"",
"===== 可用技能概览 =====",
skillMetaDoc,
"",
"===== 技能详细说明 =====",
allSkillsDoc,
"===== 能力路由结果 =====",
routeDoc,
"",
"===== 本轮相关技能(按用户问题筛选) =====",
relevantSkillsDoc,
"",
"===== 可用工具 =====",
toolDoc,
@@ -190,25 +280,32 @@ func (o *Orchestrator) buildUnifiedSystemPrompt() string {
"决策规则:",
"1) 如果你可以直接回答用户问题(不需要任何工具):",
" 设 is_final_answer=trueaction=\"none\"final_answer 填写完整回复。",
"2) 如果你需要调用工具获取信息后才能回答:",
"2) 优先判断是否可通过原子工具能力完成任务;若可完成,直接进行工具调用链路。",
"3) 当纯工具调用无法满足时,再结合已加载的技能详细说明进行决策。",
"4) 如果你需要调用工具获取信息后才能回答:",
" 设 is_final_answer=falseaction 填工具名action_input 填工具所需输入final_answer=null。",
"3) 不要在 JSON 之外输出任何内容。",
"4) 根据技能说明中的指引决定何时以及如何使用工具。",
"5) 每轮工具调用结果会以 Observation 的形式追加到推理记录中,供你下一轮决策参考。",
"5) 不要在 JSON 之外输出任何内容。",
"6) 根据技能说明中的指引决定何时以及如何使用工具。",
"7) 工具能力是全局可用的,不依赖技能命中;当技能不匹配时,仍可直接选择合适工具。",
"8) 若技能中存在与当前运行环境不匹配的章节(如 Windows 专章),应降低优先级,除非用户明确要求该环境。",
"9) 每轮工具调用结果会以 Observation 的形式追加到推理记录中,供你下一轮决策参考。",
}, "\n")
}
// runUnifiedReAct 执行统一的 ReAct 循环。
// LLM 每次都看到完整的技能集+工具集,自行决定是否调用工具或直接回答。
// 循环持续到 is_final_answer=true 或达到安全上限。
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string) (string, error) {
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, routeInput string, route capabilityRoutingResult) (string, error) {
traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID
systemPrompt := o.buildUnifiedSystemPrompt()
if strings.TrimSpace(routeInput) == "" {
routeInput = composeRouteInput(userInput, fileCtx.Summary)
}
systemPrompt := o.buildUnifiedSystemPrompt(routeInput, route)
if o.log != nil {
o.log.Infof("%s unified react start", traceLogPrefix)
o.log.Infof("%s unified react start route_need_skills=%v route_tools=%v route_skills=%d fallback=%v", traceLogPrefix, route.NeedSkills, route.SelectedToolNames, len(route.SelectedSkills), route.UsedFallback)
}
// 安全上限:防止无限循环(当前暂不使用 reactMaxStep 配置约束,使用固定硬上限)
@@ -229,13 +326,16 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
"用户问题:",
userInput,
"",
"文件上下文:",
defaultIfEmpty(fileCtx.Summary, "(none)"),
"",
"当前推理记录(按时间顺序):",
scratchpad,
"",
"请输出你的 JSON 决策。",
}, "\n")
raw, err := o.llm.Generate(ctx, systemPrompt, prompt)
raw, err := o.generateWithOptionalFiles(ctx, systemPrompt, prompt, fileCtx.FileIDs)
if err != nil {
return "", err
}
@@ -335,15 +435,514 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
return "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
}
// formatAllSkillsContent 返回所有技能的完整内容,用于注入到 system prompt 中。
func (o *Orchestrator) formatAllSkillsContent() string {
skills := o.getSkillsSnapshot()
func composeRouteInput(userInput, fileSummary string) string {
userInput = strings.TrimSpace(userInput)
fileSummary = strings.TrimSpace(fileSummary)
if userInput == "" {
return fileSummary
}
if fileSummary == "" {
return userInput
}
return userInput + "\n\n" + fileSummary
}
func (o *Orchestrator) prepareFilePromptContext(ctx context.Context, files []llm.InputFile, pending []pendingFileRef) filePromptContext {
ctxOut := filePromptContext{}
if len(pending) > 0 {
for _, p := range pending {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
ctxOut.FileIDs = append(ctxOut.FileIDs, id)
}
}
if len(files) == 0 {
ctxOut.Summary = buildFileSummary(pending, nil)
return ctxOut
}
uploader, ok := o.llm.(llm.FileUploader)
if !ok {
return filePromptContext{FatalReason: "检测到文件输入,但当前 LLM 客户端不支持文件上传接口。"}
}
uploaded := make([]pendingFileRef, 0, len(files))
for i, f := range files {
if strings.TrimSpace(f.FileName) == "" || len(f.Content) == 0 {
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] 缺少文件名或内容,无法上传。", i+1)}
}
fileID, err := uploader.UploadFile(ctx, f, "file-extract")
if err != nil {
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] name=%s 上传失败: %v", i+1, f.FileName, err)}
}
ctxOut.FileIDs = append(ctxOut.FileIDs, fileID)
uploaded = append(uploaded, pendingFileRef{
ID: fileID,
Name: strings.TrimSpace(f.FileName),
MimeType: defaultIfEmpty(strings.TrimSpace(f.MimeType), "application/octet-stream"),
})
}
ctxOut.Uploaded = uploaded
ctxOut.Summary = buildFileSummary(pending, uploaded)
return ctxOut
}
func buildFileSummary(pending, uploaded []pendingFileRef) string {
if len(pending) == 0 && len(uploaded) == 0 {
return ""
}
lines := make([]string, 0, len(pending)+len(uploaded)+2)
lines = append(lines, "以下文件 file_id 可用于本轮问答:")
idx := 1
for _, p := range pending {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
lines = append(lines, fmt.Sprintf("- cached_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
idx++
}
for _, p := range uploaded {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
lines = append(lines, fmt.Sprintf("- uploaded_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
idx++
}
if len(lines) == 1 {
return ""
}
return strings.Join(lines, "\n")
}
func (o *Orchestrator) generateWithOptionalFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
ids := nonEmptyIDs(fileIDs)
if len(ids) == 0 {
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
client, ok := o.llm.(llm.FileChatClient)
if !ok {
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids)
}
func (o *Orchestrator) buildFileUploadAck(ctx filePromptContext) string {
if len(ctx.FileIDs) == 0 {
return "文件已接收,但未拿到有效 file_id。请重新上传一次。"
}
lines := []string{
fmt.Sprintf("文件上传完成,已缓存 %d 个 file_id。", len(ctx.FileIDs)),
"请继续发送你的问题,我会结合这些文件内容和历史对话一起回答。",
}
if strings.TrimSpace(ctx.Summary) != "" {
lines = append(lines, "", ctx.Summary)
}
return strings.Join(lines, "\n")
}
func nonEmptyIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (c filePromptContext) toPendingRefs() []pendingFileRef {
if len(c.Uploaded) > 0 {
copied := make([]pendingFileRef, len(c.Uploaded))
copy(copied, c.Uploaded)
return sanitizePendingRefs(copied)
}
ids := nonEmptyIDs(c.FileIDs)
out := make([]pendingFileRef, 0, len(ids))
for _, id := range ids {
out = append(out, pendingFileRef{ID: id})
}
return out
}
func (o *Orchestrator) appendPendingFiles(chatID, userID string, refs []pendingFileRef) {
refs = sanitizePendingRefs(refs)
if len(refs) == 0 {
return
}
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
merged := append(o.pendingFiles[key], refs...)
o.pendingFiles[key] = sanitizePendingRefs(merged)
}
func (o *Orchestrator) getPendingFiles(chatID, userID string) []pendingFileRef {
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
snapshot := o.pendingFiles[key]
out := make([]pendingFileRef, len(snapshot))
copy(out, snapshot)
return out
}
func (o *Orchestrator) clearPendingFiles(chatID, userID string) {
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
delete(o.pendingFiles, key)
}
func pendingFileKey(chatID, userID string) string {
return strings.TrimSpace(chatID) + "::" + strings.TrimSpace(userID)
}
func sanitizePendingRefs(refs []pendingFileRef) []pendingFileRef {
if len(refs) == 0 {
return nil
}
out := make([]pendingFileRef, 0, len(refs))
seen := map[string]struct{}{}
for _, r := range refs {
id := strings.TrimSpace(r.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
r.ID = id
r.Name = strings.TrimSpace(r.Name)
r.MimeType = strings.TrimSpace(r.MimeType)
out = append(out, r)
}
return out
}
func defaultIfEmpty(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
// formatRelevantSkillsForPrompt 返回与当前用户问题最相关的技能内容。
func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string {
skills := selected
if len(skills) == 0 {
return "(none)"
skills = o.selectRelevantSkills(userInput, 4)
}
if len(skills) == 0 {
return "(none matched, tools are still globally available)"
}
return formatSkills(skills)
}
func (o *Orchestrator) routeCapabilities(ctx context.Context, userInput string) capabilityRoutingResult {
fallback := capabilityRoutingResult{
NeedSkills: true,
SelectedSkills: o.selectRelevantSkills(userInput, 4),
Reason: "router fallback: keyword matching",
UsedFallback: true,
}
raw, err := o.llm.Generate(ctx, o.buildRouteSystemPrompt(), o.buildRouteUserPrompt(userInput))
if err != nil {
if o.log != nil {
o.log.Warnf("capability router llm call failed err=%v", err)
}
return fallback
}
decision, err := parseCapabilityRoute(raw)
if err != nil {
if o.log != nil {
o.log.Warnf("capability router parse failed err=%v raw=%q", err, raw)
}
return fallback
}
resolvedTools := o.normalizeToolSelection(decision.SelectedTools)
resolved := capabilityRoutingResult{
NeedSkills: decision.NeedSkills,
SelectedToolNames: resolvedTools,
Reason: strings.TrimSpace(decision.Reason),
}
if resolved.NeedSkills {
skills := o.resolveSkillsByNames(decision.SelectedSkills, 4)
if len(skills) == 0 {
skills = o.selectRelevantSkills(userInput, 4)
resolved.UsedFallback = true
}
resolved.SelectedSkills = skills
}
return resolved
}
func (o *Orchestrator) buildRouteSystemPrompt() string {
return strings.Join([]string{
"你是能力路由器Router Agent。",
"你的任务是:在不加载技能全文的前提下,仅根据工具摘要和技能摘要,判断本请求是否可以仅靠原子工具能力完成,还是需要加载技能详细说明。",
"输出必须且仅能是 JSON",
"{",
" \"need_skills\": true 或 false,",
" \"selected_tools\": [\"tool_name\", ...],",
" \"selected_skills\": [\"skill_name\", ...],",
" \"reason\": \"简短路由理由\"",
"}",
"规则:",
"1) 优先原子工具能力。若可通过工具链路完成need_skills=false。",
"2) 只有当工具能力不足以覆盖业务约束时need_skills=true 并选择少量最相关技能。",
"3) selected_skills 仅填写技能名称(来自技能摘要)。",
"4) selected_tools 仅填写可用工具名。",
"5) 不要输出 JSON 之外内容。",
}, "\n")
}
func (o *Orchestrator) buildRouteUserPrompt(userInput string) string {
return strings.Join([]string{
"当前运行环境:",
formatRuntimeContextForPrompt(),
"",
"用户问题:",
userInput,
"",
"可用工具摘要:",
o.formatToolDoc(),
"",
"可用技能摘要:",
o.formatSkillSummariesForPrompt(),
"",
"请给出路由 JSON。",
}, "\n")
}
func (o *Orchestrator) normalizeToolSelection(in []string) []string {
if len(in) == 0 {
return nil
}
allowed := map[string]struct{}{}
for _, t := range o.tools.List() {
allowed[strings.ToLower(strings.TrimSpace(t.Name()))] = struct{}{}
}
out := make([]string, 0, len(in))
set := map[string]struct{}{}
for _, name := range in {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
continue
}
if _, ok := allowed[n]; !ok {
continue
}
if _, exists := set[n]; exists {
continue
}
set[n] = struct{}{}
out = append(out, n)
}
sort.Strings(out)
return out
}
func (o *Orchestrator) resolveSkillsByNames(names []string, maxCount int) []knowledge.Skill {
if len(names) == 0 {
return nil
}
if maxCount <= 0 {
maxCount = 4
}
all := o.getSkillsSnapshot()
idx := make(map[string]knowledge.Skill, len(all))
for _, sk := range all {
key := strings.ToLower(strings.TrimSpace(sk.Name))
if key != "" {
idx[key] = sk
}
}
out := make([]knowledge.Skill, 0, maxCount)
used := map[string]struct{}{}
for _, name := range names {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
continue
}
sk, ok := idx[key]
if !ok {
continue
}
if _, exists := used[key]; exists {
continue
}
used[key] = struct{}{}
out = append(out, sk)
if len(out) >= maxCount {
break
}
}
return out
}
func formatRouteForPrompt(route capabilityRoutingResult) string {
b := strings.Builder{}
if route.UsedFallback {
b.WriteString("router_status: fallback\n")
} else {
b.WriteString("router_status: ok\n")
}
b.WriteString("need_skills: ")
b.WriteString(strconv.FormatBool(route.NeedSkills))
b.WriteString("\n")
b.WriteString("selected_tools: ")
if len(route.SelectedToolNames) == 0 {
b.WriteString("(none)")
} else {
b.WriteString(strings.Join(route.SelectedToolNames, ", "))
}
b.WriteString("\n")
b.WriteString("selected_skill_count: ")
b.WriteString(strconv.Itoa(len(route.SelectedSkills)))
b.WriteString("\n")
if strings.TrimSpace(route.Reason) != "" {
b.WriteString("reason: ")
b.WriteString(strings.TrimSpace(route.Reason))
}
return strings.TrimSpace(b.String())
}
func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []knowledge.Skill {
if maxCount <= 0 {
maxCount = 4
}
query := strings.TrimSpace(strings.ToLower(userInput))
all := o.getSkillsSnapshot()
if query == "" || len(all) <= maxCount {
return all
}
queryTokens := buildQueryTokens(query)
type item struct {
skill knowledge.Skill
score int
}
ranked := make([]item, 0, len(all))
for _, sk := range all {
hay := strings.ToLower(sk.Name + "\n" + clipForScoring(sk.Content, 1800))
score := 0
if strings.Contains(hay, query) {
score += 8
}
for _, tk := range queryTokens {
if strings.Contains(hay, tk) {
score++
}
}
if score == 0 {
continue
}
ranked = append(ranked, item{skill: sk, score: score})
}
if len(ranked) == 0 {
return nil
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].score == ranked[j].score {
return strings.ToLower(strings.TrimSpace(ranked[i].skill.Name)) < strings.ToLower(strings.TrimSpace(ranked[j].skill.Name))
}
return ranked[i].score > ranked[j].score
})
if len(ranked) > maxCount {
ranked = ranked[:maxCount]
}
out := make([]knowledge.Skill, 0, len(ranked))
for _, r := range ranked {
out = append(out, r.skill)
}
return out
}
func buildQueryTokens(query string) []string {
set := map[string]struct{}{}
collectToken := func(t string) {
t = strings.TrimSpace(t)
if len([]rune(t)) < 2 {
return
}
set[t] = struct{}{}
}
for _, part := range strings.FieldsFunc(query, func(r rune) bool {
if r >= 'a' && r <= 'z' {
return false
}
if r >= '0' && r <= '9' {
return false
}
if r >= 0x4e00 && r <= 0x9fff {
return false
}
return true
}) {
collectToken(part)
}
// 针对中文无空格输入,补充 2-gram 提升匹配命中率。
runes := []rune(query)
for i := 0; i+1 < len(runes); i++ {
r1 := runes[i]
r2 := runes[i+1]
if (r1 >= 0x4e00 && r1 <= 0x9fff) && (r2 >= 0x4e00 && r2 <= 0x9fff) {
collectToken(string([]rune{r1, r2}))
}
}
out := make([]string, 0, len(set))
for tk := range set {
out = append(out, tk)
}
sort.Strings(out)
return out
}
func clipForScoring(s string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = 1800
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes])
}
func formatRuntimeContextForPrompt() string {
goos := strings.TrimSpace(strings.ToLower(runtime.GOOS))
if goos == "" {
goos = "unknown"
}
return "当前运行系统 GOOS=" + goos + "。请优先使用与该系统一致的策略。仅当用户明确要求时,才采用其他系统(如 Windows的专用流程。"
}
// emitCapabilityGap 处理能力缺口信息埋点或者通过 AI 自动创建生成相应缺失技能的逻辑
func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string) {
if !o.enableCapabilityGap {