Version: 0.9.45.dev.260427
后端: 1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜 2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写 3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态 4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分 5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定 前端: 6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作 仓库: 7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件 PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
This commit is contained in:
37
backend/newAgent/tools/active_optimize.go
Normal file
37
backend/newAgent/tools/active_optimize.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package newagenttools
|
||||
|
||||
import "strings"
|
||||
|
||||
var activeOptimizeAllowedTools = map[string]struct{}{
|
||||
ToolNameContextToolsAdd: {},
|
||||
ToolNameContextToolsRemove: {},
|
||||
"analyze_health": {},
|
||||
"move": {},
|
||||
"swap": {},
|
||||
}
|
||||
|
||||
// IsToolAllowedInActiveOptimize 判定工具是否允许出现在“粗排后主动优化专用模式”里。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 这里只做场景级白名单裁剪,不参与工具是否已注册、是否被临时禁用、是否需要 confirm 的判断;
|
||||
// 2. 该白名单只服务于“首次粗排后自动微调”链路,避免 LLM 在主动优化时重新暴露大量读工具;
|
||||
// 3. context_tools_add/remove 仍保留,是为了兼容系统级动态区协议,但不代表会重新放开其它业务工具。
|
||||
func IsToolAllowedInActiveOptimize(name string) bool {
|
||||
_, ok := activeOptimizeAllowedTools[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FilterSchemasForActiveOptimize 过滤出主动优化专用模式允许暴露给 LLM 的工具 schema。
|
||||
func FilterSchemasForActiveOptimize(schemas []ToolSchemaEntry) []ToolSchemaEntry {
|
||||
if len(schemas) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]ToolSchemaEntry, 0, len(schemas))
|
||||
for _, item := range schemas {
|
||||
if !IsToolAllowedInActiveOptimize(item.Name) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
305
backend/newAgent/tools/context_tools.go
Normal file
305
backend/newAgent/tools/context_tools.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
type contextToolsAddResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Action string `json:"action"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type contextToolsRemoveResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
Action string `json:"action"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Packs []string `json:"packs,omitempty"`
|
||||
All bool `json:"all,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
// NewContextToolsAddHandler 创建 context_tools_add 工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态;
|
||||
// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState;
|
||||
// 3. schedule 支持可选 packs;taskclass 目前不支持可选 packs。
|
||||
func NewContextToolsAddHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
|
||||
if domain == "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:domain 仅支持 schedule/taskclass",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
|
||||
if mode == "" {
|
||||
mode = "replace"
|
||||
}
|
||||
if mode != "replace" && mode != "merge" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: "参数非法:mode 仅支持 replace/merge",
|
||||
ErrorCode: "invalid_mode",
|
||||
})
|
||||
}
|
||||
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
// schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。
|
||||
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
|
||||
packs = ResolveEffectiveToolPacks(domain, nil)
|
||||
}
|
||||
|
||||
return marshalContextToolsAddResult(contextToolsAddResult{
|
||||
Tool: ToolNameContextToolsAdd,
|
||||
Success: true,
|
||||
Action: "activate",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Mode: mode,
|
||||
Message: "已激活工具域,可继续调用对应业务工具。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储;
|
||||
// 2. all=true 表示清空动态区业务工具;domain+packs 表示移除该域下指定二级包;
|
||||
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。
|
||||
func NewContextToolsRemoveHandler() ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
all := readContextToolBool(args["all"])
|
||||
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
|
||||
packsRaw := readContextToolStringSlice(args["packs"])
|
||||
|
||||
// 兼容写法:domain=all 视为清空全部。
|
||||
if domainRaw == "all" {
|
||||
all = true
|
||||
}
|
||||
if all {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "clear_all",
|
||||
All: true,
|
||||
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
|
||||
})
|
||||
}
|
||||
|
||||
domain := NormalizeToolDomain(domainRaw)
|
||||
if domain == "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
|
||||
ErrorCode: "invalid_domain",
|
||||
})
|
||||
}
|
||||
|
||||
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
|
||||
if errCode != "" {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: false,
|
||||
Action: "reject",
|
||||
Domain: domain,
|
||||
Error: errText,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
}
|
||||
|
||||
if len(packs) > 0 {
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate_packs",
|
||||
Domain: domain,
|
||||
Packs: packs,
|
||||
Message: "已移除指定工具包。",
|
||||
})
|
||||
}
|
||||
|
||||
return marshalContextToolsRemoveResult(contextToolsRemoveResult{
|
||||
Tool: ToolNameContextToolsRemove,
|
||||
Success: true,
|
||||
Action: "deactivate",
|
||||
Domain: domain,
|
||||
Message: "已移除指定工具域。",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateContextPacks(domain string, packs []string, forRemove bool) ([]string, string, string) {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" {
|
||||
return nil, "invalid_domain", "参数非法:domain 非法"
|
||||
}
|
||||
if len(packs) == 0 {
|
||||
return nil, "", ""
|
||||
}
|
||||
|
||||
if normalizedDomain == ToolDomainTaskClass {
|
||||
return nil, "unsupported_packs_for_domain", "参数非法:taskclass 暂不支持 packs"
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(packs))
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
for _, raw := range packs {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
pack := NormalizeToolPack(normalizedDomain, trimmed)
|
||||
if pack == "" {
|
||||
return nil, "invalid_pack", "参数非法:存在不支持的 pack"
|
||||
}
|
||||
if IsFixedToolPack(normalizedDomain, pack) {
|
||||
if forRemove {
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 remove"
|
||||
}
|
||||
return nil, "fixed_pack_forbidden", "参数非法:core 为固定包,不允许 add"
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
normalized = append(normalized, pack)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, "invalid_pack", "参数非法:packs 为空或无效"
|
||||
}
|
||||
return normalized, "", ""
|
||||
}
|
||||
|
||||
func readContextToolString(raw any) string {
|
||||
text, _ := raw.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readContextToolStringSlice(raw any) []string {
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text := strings.TrimSpace(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(text, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readContextToolBool(raw any) bool {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
value := strings.ToLower(strings.TrimSpace(v))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
case float64:
|
||||
return v != 0
|
||||
case float32:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
case int8:
|
||||
return v != 0
|
||||
case int16:
|
||||
return v != 0
|
||||
case int32:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalContextToolsAddResult(result contextToolsAddResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_add","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func marshalContextToolsRemoveResult(result contextToolsRemoveResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"context_tools_remove","success":false,"action":"reject","error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -10,36 +10,62 @@ import (
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
|
||||
)
|
||||
|
||||
// ToolHandler 是所有工具的统一执行签名。
|
||||
// ToolHandler 约定所有工具的统一执行签名。
|
||||
// 职责边界:
|
||||
// 1. 负责消费当前 ScheduleState 与模型传入参数;
|
||||
// 2. 返回统一 string 结果,供 execute 节点写回 observation;
|
||||
// 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。
|
||||
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
|
||||
|
||||
// ToolSchemaEntry 是注入给模型的工具说明快照。
|
||||
// ToolSchemaEntry 描述注入给模型的工具快照。
|
||||
type ToolSchemaEntry struct {
|
||||
Name string
|
||||
Desc string
|
||||
SchemaText string
|
||||
}
|
||||
|
||||
// DefaultRegistryDeps 描述默认工具注册表可选依赖。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这层依赖注入先为后续 websearch / memory 工具预留统一入口;
|
||||
// 2. 当前即便部分依赖暂未使用,也不应让业务侧再自行 new 底层 Infra;
|
||||
// 3. 后续新增读工具时,应优先在这里扩展依赖而不是走包级全局变量。
|
||||
// DefaultRegistryDeps 描述默认注册表需要的外部依赖。
|
||||
// 职责边界:
|
||||
// 1. 这里只承载工具层需要的依赖注入,不承载业务状态;
|
||||
// 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new;
|
||||
// 3. 具体依赖缺失时由对应工具自行返回结构化失败结果。
|
||||
type DefaultRegistryDeps struct {
|
||||
RAGRuntime infrarag.Runtime
|
||||
|
||||
// WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。
|
||||
// WebSearchProvider 为 nil 时,web_search / web_fetch 仍会注册,
|
||||
// 但 handler 会返回“暂未启用”的只读 observation,不阻断主流程。
|
||||
WebSearchProvider web.SearchProvider
|
||||
|
||||
// TaskClassWriteDeps 供 upsert_task_class 调用持久化层。
|
||||
TaskClassWriteDeps TaskClassWriteDeps
|
||||
}
|
||||
|
||||
// ToolRegistry 管理工具注册、查找与执行。
|
||||
// ToolRegistry 管理工具注册、过滤与执行。
|
||||
type ToolRegistry struct {
|
||||
handlers map[string]ToolHandler
|
||||
schemas []ToolSchemaEntry
|
||||
deps DefaultRegistryDeps
|
||||
}
|
||||
|
||||
// temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。
|
||||
// 设计说明:
|
||||
// 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字;
|
||||
// 2. execute 会在调用前统一阻断,并向模型返回纠错提示;
|
||||
// 3. ToolNames / Schemas 也会默认隐藏它们,避免继续污染 msg0。
|
||||
var temporaryDisabledTools = map[string]bool{
|
||||
"min_context_switch": true,
|
||||
"spread_even": true,
|
||||
"analyze_load": true,
|
||||
"analyze_subjects": true,
|
||||
"analyze_context": true,
|
||||
"analyze_tolerance": true,
|
||||
}
|
||||
|
||||
// IsTemporarilyDisabledTool 判断工具是否在当前阶段被临时禁用。
|
||||
func IsTemporarilyDisabledTool(name string) bool {
|
||||
return temporaryDisabledTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// NewToolRegistry 创建空注册表。
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return NewToolRegistryWithDeps(DefaultRegistryDeps{})
|
||||
@@ -65,7 +91,14 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
|
||||
}
|
||||
|
||||
// Execute 执行指定工具。
|
||||
// 职责边界:
|
||||
// 1. 这里只负责找到 handler 并调用;
|
||||
// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler;
|
||||
// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。
|
||||
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string {
|
||||
if r.IsToolTemporarilyDisabled(toolName) {
|
||||
return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName))
|
||||
}
|
||||
handler, ok := r.handlers[toolName]
|
||||
if !ok {
|
||||
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、"))
|
||||
@@ -73,41 +106,126 @@ func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, a
|
||||
return handler(state, args)
|
||||
}
|
||||
|
||||
// HasTool 检查工具是否已注册。
|
||||
// HasTool 判断工具是否已注册且当前可见。
|
||||
func (r *ToolRegistry) HasTool(name string) bool {
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
return false
|
||||
}
|
||||
_, ok := r.handlers[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ToolNames 返回已注册工具名(按 schema 顺序)。
|
||||
// IsToolTemporarilyDisabled 判断工具是否处于“已注册但暂不允许调用”状态。
|
||||
func (r *ToolRegistry) IsToolTemporarilyDisabled(name string) bool {
|
||||
return IsTemporarilyDisabledTool(name)
|
||||
}
|
||||
|
||||
// ToolNames 返回当前可暴露给模型的工具名。
|
||||
func (r *ToolRegistry) ToolNames() []string {
|
||||
names := make([]string, 0, len(r.handlers))
|
||||
names := make([]string, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Schemas 返回 schema 快照。
|
||||
// Schemas 返回当前可暴露给模型的 schema 快照。
|
||||
func (r *ToolRegistry) Schemas() []ToolSchemaEntry {
|
||||
result := make([]ToolSchemaEntry, len(r.schemas))
|
||||
copy(result, r.schemas)
|
||||
result := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
if r.IsToolTemporarilyDisabled(item.Name) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsWriteTool 判断工具是否是写工具(需要 confirm)。
|
||||
// SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。
|
||||
// 职责边界:
|
||||
// 1. context_tools_add/remove 始终保留,用于动态区协议;
|
||||
// 2. 仅当工具域已激活时,才暴露该域下可见工具;
|
||||
// 3. schedule 域支持按 pack 过滤;taskclass 目前只有 core。
|
||||
func (r *ToolRegistry) SchemasForActiveDomain(activeDomain string, activePacks []string) []ToolSchemaEntry {
|
||||
normalizedDomain := NormalizeToolDomain(activeDomain)
|
||||
effectivePacks := ResolveEffectiveToolPacks(normalizedDomain, activePacks)
|
||||
effectivePackSet := make(map[string]struct{}, len(effectivePacks))
|
||||
for _, pack := range effectivePacks {
|
||||
effectivePackSet[pack] = struct{}{}
|
||||
}
|
||||
|
||||
selected := make([]ToolSchemaEntry, 0, len(r.schemas))
|
||||
for _, item := range r.schemas {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if r.IsToolTemporarilyDisabled(name) {
|
||||
continue
|
||||
}
|
||||
if IsContextManagementTool(name) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if normalizedDomain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
domain, pack, ok := ResolveToolDomainPack(name)
|
||||
if !ok {
|
||||
// 兼容历史未建档工具:仅在 schedule 域下继续暴露,避免突然失联。
|
||||
if normalizedDomain == ToolDomainSchedule {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if domain != normalizedDomain {
|
||||
continue
|
||||
}
|
||||
if IsFixedToolPack(domain, pack) {
|
||||
selected = append(selected, item)
|
||||
continue
|
||||
}
|
||||
if _, exists := effectivePackSet[pack]; exists {
|
||||
selected = append(selected, item)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ToolSchemaEntry, len(selected))
|
||||
copy(result, selected)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsToolVisibleInDomain 判断某工具在当前动态区下是否应对模型可见。
|
||||
func (r *ToolRegistry) IsToolVisibleInDomain(activeDomain string, activePacks []string, toolName string) bool {
|
||||
name := strings.TrimSpace(toolName)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range r.SchemasForActiveDomain(activeDomain, activePacks) {
|
||||
if strings.TrimSpace(item.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsWriteTool 判断工具是否属于写工具。
|
||||
func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
return writeTools[name]
|
||||
return writeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。
|
||||
// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。
|
||||
func (r *ToolRegistry) IsScheduleMutationTool(name string) bool {
|
||||
return scheduleMutationTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// RequiresScheduleState 判断工具是否依赖 ScheduleState。
|
||||
// 调用目的:execute 节点据此决定是否允许在 ScheduleState 为 nil 时调用该工具。
|
||||
func (r *ToolRegistry) RequiresScheduleState(name string) bool {
|
||||
return !scheduleFreeTools[name]
|
||||
return !scheduleFreeTools[strings.TrimSpace(name)]
|
||||
}
|
||||
|
||||
// ==================== 写工具集合 ====================
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
@@ -117,38 +235,83 @@ var writeTools = map[string]bool{
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
"upsert_task_class": true,
|
||||
}
|
||||
|
||||
// ==================== 不依赖 ScheduleState 的工具集合 ====================
|
||||
// 调用目的:这些工具不需要日程状态即可执行,execute 节点在 ScheduleState 为 nil 时允许调用。
|
||||
var scheduleMutationTools = map[string]bool{
|
||||
"place": true,
|
||||
"move": true,
|
||||
"swap": true,
|
||||
"batch_move": true,
|
||||
"queue_apply_head_move": true,
|
||||
"spread_even": true,
|
||||
"min_context_switch": true,
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// scheduleFreeTools 描述“即使没有 ScheduleState 也能安全执行”的工具。
|
||||
var scheduleFreeTools = map[string]bool{
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
"upsert_task_class": true,
|
||||
ToolNameContextToolsAdd: true,
|
||||
ToolNameContextToolsRemove: true,
|
||||
}
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
|
||||
// NewDefaultRegistry 创建默认日程工具注册表。
|
||||
// NewDefaultRegistry 创建默认注册表。
|
||||
func NewDefaultRegistry() *ToolRegistry {
|
||||
return NewDefaultRegistryWithDeps(DefaultRegistryDeps{})
|
||||
}
|
||||
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认日程工具注册表。
|
||||
// NewDefaultRegistryWithDeps 创建带依赖的默认注册表。
|
||||
// 步骤化说明:
|
||||
// 1. 先注册上下文管理工具,保证动态区协议随时可用;
|
||||
// 2. 再注册 schedule 域的读、诊断、写工具;
|
||||
// 3. 最后注册 taskclass 与 web 工具,并统一按 name 排序,保证 prompt 输出稳定。
|
||||
func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
r := NewToolRegistryWithDeps(deps)
|
||||
|
||||
// --- 读工具 ---
|
||||
r.Register("get_overview",
|
||||
"获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
|
||||
registerContextTools(r)
|
||||
registerScheduleReadTools(r)
|
||||
registerScheduleAnalyzeTools(r)
|
||||
registerScheduleMutationTools(r)
|
||||
registerTaskClassTools(r, deps)
|
||||
registerWebTools(r, deps)
|
||||
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func registerContextTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
ToolNameContextToolsAdd,
|
||||
"激活指定工具域,并可附带 schedule 二级包 packs。core 固定注入。",
|
||||
`{"name":"context_tools_add","parameters":{"domain":{"type":"string","required":true,"enum":["schedule","taskclass"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"mode":{"type":"string","enum":["replace","merge"]}}}`,
|
||||
NewContextToolsAddHandler(),
|
||||
)
|
||||
r.Register(
|
||||
ToolNameContextToolsRemove,
|
||||
"移除指定工具域、指定二级包,或清空全部业务工具域(all=true)。core 固定包不支持 remove。",
|
||||
`{"name":"context_tools_remove","parameters":{"domain":{"type":"string","enum":["schedule","taskclass","all"]},"packs":{"type":"array","items":{"type":"string","enum":["mutation","analyze","detail_read","deep_analyze","queue","web"]}},"all":{"type":"bool"}}}`,
|
||||
NewContextToolsRemoveHandler(),
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleReadTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"get_overview",
|
||||
"获取当前窗口总览:保留课程占位统计,展开任务清单。",
|
||||
`{"name":"get_overview","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = args
|
||||
return schedule.GetOverview(state)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_range",
|
||||
"查看某天或某时段的细粒度占用详情。day 必填,slot_start/slot_end 选填(不填查整天)。",
|
||||
r.Register(
|
||||
"query_range",
|
||||
"查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。",
|
||||
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
day, ok := schedule.ArgsInt(args, "day")
|
||||
@@ -158,41 +321,41 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_available_slots",
|
||||
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。",
|
||||
r.Register(
|
||||
"query_available_slots",
|
||||
"查询候选空位池,适合 move 前筛落点。",
|
||||
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryAvailableSlots(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。",
|
||||
r.Register(
|
||||
"query_target_tasks",
|
||||
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。",
|
||||
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueryTargetTasks(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。",
|
||||
r.Register(
|
||||
"queue_pop_head",
|
||||
"弹出并返回当前队首任务;若已有 current 则复用。",
|
||||
`{"name":"queue_pop_head","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueuePopHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_status",
|
||||
"查看当前待处理队列状态(pending/current/completed/skipped)。",
|
||||
r.Register(
|
||||
"queue_status",
|
||||
"查看当前队列状态(pending/current/completed/skipped)。",
|
||||
`{"name":"queue_status","parameters":{}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueStatus(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("get_task_info",
|
||||
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
|
||||
r.Register(
|
||||
"get_task_info",
|
||||
"查看单个任务详情,包括类别、状态与落位。",
|
||||
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -202,10 +365,63 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.GetTaskInfo(state, taskID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- 写工具 ---
|
||||
r.Register("place",
|
||||
"将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。",
|
||||
func registerScheduleAnalyzeTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"analyze_load",
|
||||
"分析整体负载分布(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_load","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"granularity":{"type":"string","enum":["day","week","time_of_day"]},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeLoad(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_subjects",
|
||||
"分析学科分布与连贯性(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_subjects","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeSubjects(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_context",
|
||||
"分析上下文切换与相邻关系(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"analyze_context","parameters":{"day_from":{"type":"int"},"day_to":{"type":"int"},"detail":{"type":"string","enum":["summary","day_detail"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeContext(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_rhythm",
|
||||
"分析学习节奏与切换情况。",
|
||||
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeRhythm(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_tolerance",
|
||||
"分析局部容错与调整空间。",
|
||||
`{"name":"analyze_tolerance","parameters":{"scope":{"type":"string","enum":["full","week","day_range"]},"week_from":{"type":"int"},"week_to":{"type":"int"},"day_from":{"type":"int"},"day_to":{"type":"int"},"min_usable_size":{"type":"int"},"min_daily_buffer":{"type":"int"},"detail":{"type":"string","enum":["summary","full"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeTolerance(state, args)
|
||||
},
|
||||
)
|
||||
r.Register(
|
||||
"analyze_health",
|
||||
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。",
|
||||
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.AnalyzeHealth(state, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func registerScheduleMutationTools(r *ToolRegistry) {
|
||||
r.Register(
|
||||
"place",
|
||||
"将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。",
|
||||
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -223,9 +439,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Place(state, taskID, day, slotStart)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。",
|
||||
r.Register(
|
||||
"move",
|
||||
"将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。",
|
||||
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -243,9 +459,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Move(state, taskID, newDay, newSlotStart)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("swap",
|
||||
"交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。",
|
||||
r.Register(
|
||||
"swap",
|
||||
"交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。",
|
||||
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskA, ok := schedule.ArgsInt(args, "task_a")
|
||||
@@ -259,9 +475,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Swap(state, taskA, taskB)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("batch_move",
|
||||
"原子性批量移动多个任务(仅 suggested,最多2条),全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。",
|
||||
r.Register(
|
||||
"batch_move",
|
||||
"原子性批量移动多个任务。moves 必填。",
|
||||
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
moves, err := schedule.ArgsMoveList(args)
|
||||
@@ -271,25 +487,25 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.BatchMove(state, moves)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。仅作用于 current,不接受 task_id。new_day/new_slot_start 必填。",
|
||||
r.Register(
|
||||
"queue_apply_head_move",
|
||||
"将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。",
|
||||
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueApplyHeadMove(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("queue_skip_head",
|
||||
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。",
|
||||
r.Register(
|
||||
"queue_skip_head",
|
||||
"跳过当前队首任务,将其标记为 skipped。",
|
||||
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
return schedule.QueueSkipHead(state, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("min_context_switch",
|
||||
"在指定任务集合内重排 suggested 任务,尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id)。",
|
||||
r.Register(
|
||||
"min_context_switch",
|
||||
"在指定任务集合内减少上下文切换(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
|
||||
@@ -299,9 +515,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.MinContextSwitch(state, taskIDs)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("spread_even",
|
||||
"在给定任务集合内做均匀化铺开:先按筛选条件收集候选坑位,再规划并原子落地。task_ids 必填(兼容 task_id)。",
|
||||
r.Register(
|
||||
"spread_even",
|
||||
"在给定任务集合内做均匀化铺开(当前阶段已临时禁用,仅保留定义)。",
|
||||
`{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
|
||||
@@ -311,9 +527,9 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.SpreadEven(state, taskIDs, args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
|
||||
r.Register(
|
||||
"unplace",
|
||||
"将一个已落位任务移除,恢复为待安排状态。task_id 必填。",
|
||||
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
taskID, ok := schedule.ArgsInt(args, "task_id")
|
||||
@@ -323,33 +539,37 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
return schedule.Unplace(state, taskID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Web 搜索读工具 ---
|
||||
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation,不会阻断主流程;
|
||||
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。
|
||||
func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
r.Register(
|
||||
"upsert_task_class",
|
||||
"创建或更新任务类(统一写入口,必须 confirm)。auto 模式下 start_date/end_date 必须在 task_class 顶层字段。",
|
||||
`{"name":"upsert_task_class","parameters":{"id":{"type":"int"},"task_class":{"type":"object","required":true},"items":{"type":"array","items":{"type":"object"}},"source":{"type":"string"}}}`,
|
||||
NewTaskClassUpsertToolHandler(deps.TaskClassWriteDeps),
|
||||
)
|
||||
}
|
||||
|
||||
func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
|
||||
webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider)
|
||||
webFetchHandler := web.NewFetchToolHandler(web.NewFetcher())
|
||||
|
||||
r.Register("web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间)。query 必填。",
|
||||
r.Register(
|
||||
"web_search",
|
||||
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
|
||||
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webSearchHandler.Handle(args)
|
||||
},
|
||||
)
|
||||
|
||||
r.Register("web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。",
|
||||
r.Register(
|
||||
"web_fetch",
|
||||
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
|
||||
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
|
||||
func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
return webFetchHandler.Handle(args)
|
||||
},
|
||||
)
|
||||
|
||||
// 按 schema name 排序,确保输出稳定。
|
||||
sort.Slice(r.schemas, func(i, j int) bool {
|
||||
return r.schemas[i].Name < r.schemas[j].Name
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
1123
backend/newAgent/tools/schedule/analyze_health_candidates.go
Normal file
1123
backend/newAgent/tools/schedule/analyze_health_candidates.go
Normal file
File diff suppressed because it is too large
Load Diff
124
backend/newAgent/tools/schedule/analyze_health_decision_v2.go
Normal file
124
backend/newAgent/tools/schedule/analyze_health_decision_v2.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package schedule
|
||||
|
||||
import "strings"
|
||||
|
||||
// buildAnalyzeHealthDecisionV2 生成 analyze_health 在主动优化场景下的最终裁决。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 先尊重 base 层的判断:只有 base 明确允许继续优化时,才进入候选枚举。
|
||||
// 2. 候选只来自后端已经验证合法、并且复诊后确实变好的 move/swap 方案。
|
||||
// 3. 若没有真正改善的候选,则明确返回 close,避免把 LLM 推回开放式全窗搜索。
|
||||
func buildAnalyzeHealthDecisionV2(
|
||||
state *ScheduleState,
|
||||
snapshot analyzeHealthSnapshot,
|
||||
) analyzeHealthDecision {
|
||||
base := buildAnalyzeHealthDecisionBase(state, snapshot)
|
||||
decision := analyzeHealthDecision{
|
||||
ShouldContinueOptimize: base.ShouldContinueOptimize,
|
||||
PrimaryProblem: base.PrimaryProblem,
|
||||
ProblemScope: base.ProblemScope,
|
||||
IsForcedImperfection: base.IsForcedImperfection,
|
||||
RecommendedOperation: base.RecommendedOperation,
|
||||
ImprovementSignal: buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
base.ProblemScope,
|
||||
base.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
),
|
||||
}
|
||||
|
||||
if !shouldEnterHealthCandidateLoop(base) {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前不需要再进入主动优化候选。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
return decision
|
||||
}
|
||||
|
||||
bestScan, ok := findBestHealthProblemScanResult(state, snapshot)
|
||||
if !ok || bestScan.Problem.Kind != healthProblemHeavyAdjacent || bestScan.Problem.Pair == nil {
|
||||
decision.Candidates = []analyzeHealthCandidate{
|
||||
buildHealthCloseCandidate("保持当前安排并收口:当前没有值得继续处理的局部认知问题。", snapshot, base),
|
||||
}
|
||||
decision.ShouldContinueOptimize = false
|
||||
decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
|
||||
decision.ProblemScope = nil
|
||||
decision.RecommendedOperation = "close"
|
||||
if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
|
||||
decision.IsForcedImperfection = true
|
||||
}
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
decision.PrimaryProblem = bestScan.Problem.Summary
|
||||
decision.ProblemScope = bestScan.Problem.Scope
|
||||
decision.Candidates = append(decision.Candidates, bestScan.Candidates...)
|
||||
decision.Candidates = append(decision.Candidates,
|
||||
buildHealthCloseCandidate("如果不想继续挪动,也可以保持当前安排并直接收口。", snapshot, base),
|
||||
)
|
||||
decision.ShouldContinueOptimize = true
|
||||
decision.RecommendedOperation = strings.TrimSpace(bestScan.Candidates[0].Tool)
|
||||
decision.ImprovementSignal = buildHealthImprovementSignal(
|
||||
snapshot.Rhythm,
|
||||
snapshot.Tightness,
|
||||
decision.ProblemScope,
|
||||
decision.RecommendedOperation,
|
||||
snapshot.Profile,
|
||||
snapshot.Feasibility,
|
||||
)
|
||||
return decision
|
||||
}
|
||||
|
||||
// findBestHealthProblemScanResult 每轮重扫所有 heavy_adjacent 天,并选出当前收益最高的一天。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先收集所有仍需关注的 heavy_adjacent 天;这里只扫描问题天,不改候选类型。
|
||||
// 2. 再对每一天复用现有单天候选试算逻辑,保持“合法且复诊后确实变好”这一过滤语义不变。
|
||||
// 3. 最后只返回收益最高且达到最小阈值的一天;最终 decision.candidates 仍只来自这一天天然候选集。
|
||||
func findBestHealthProblemScanResult(
|
||||
state *ScheduleState,
|
||||
snapshot analyzeHealthSnapshot,
|
||||
) (analyzeHealthProblemScanResult, bool) {
|
||||
problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
|
||||
if len(problems) == 0 {
|
||||
return analyzeHealthProblemScanResult{}, false
|
||||
}
|
||||
|
||||
results := make([]analyzeHealthProblemScanResult, 0, len(problems))
|
||||
for _, problem := range problems {
|
||||
scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
results = append(results, scan)
|
||||
}
|
||||
return selectBestHealthProblemScanResult(results)
|
||||
}
|
||||
|
||||
// shouldEnterHealthCandidateLoop 判断本轮是否应进入“候选式主动优化”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 只有 base 已判定“值得继续优化”时才放行。
|
||||
// 2. 当前主动优化闭环只接受 move / swap 两类操作,其它动作不进入候选生成。
|
||||
// 3. 这样可以挡住 “ask_user / close / forced imperfection” 被后续枚举误覆盖的问题。
|
||||
func shouldEnterHealthCandidateLoop(base analyzeHealthDecisionBase) bool {
|
||||
if !base.ShouldContinueOptimize {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(base.RecommendedOperation) {
|
||||
case "move", "swap":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
1478
backend/newAgent/tools/schedule/analyze_tools.go
Normal file
1478
backend/newAgent/tools/schedule/analyze_tools.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
)
|
||||
}
|
||||
}
|
||||
minContextProposals := make(map[int][]TaskSlot, len(afterByID))
|
||||
for taskID, after := range afterByID {
|
||||
minContextProposals[taskID] = []TaskSlot{after.Slot}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, minContextProposals); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 4. 全量通过后再原子提交,避免半成品状态。
|
||||
clone := state.Clone()
|
||||
@@ -256,6 +263,13 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
|
||||
)
|
||||
}
|
||||
}
|
||||
spreadEvenProposals := make(map[int][]TaskSlot, len(afterByID))
|
||||
for taskID, after := range afterByID {
|
||||
spreadEvenProposals[taskID] = []TaskSlot{after.Slot}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, spreadEvenProposals); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
clone := state.Clone()
|
||||
for taskID, after := range afterByID {
|
||||
|
||||
184
backend/newAgent/tools/schedule/order_constraints.go
Normal file
184
backend/newAgent/tools/schedule/order_constraints.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package schedule
|
||||
|
||||
import "fmt"
|
||||
|
||||
// validateLocalOrderForSinglePlacement 校验单个任务落到目标时段后,是否仍满足同任务类内部顺序约束。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责“同任务类内部顺序”这一条规则,不负责冲突、锁定、范围合法性;
|
||||
// 2. 采用“克隆态 + 假设落位”方式校验,避免直接污染真实 state;
|
||||
// 3. 若任务不属于 task_item / 缺少 task_order / 当前无边界约束,直接放行。
|
||||
func validateLocalOrderForSinglePlacement(state *ScheduleState, taskID int, targetSlots []TaskSlot) error {
|
||||
if len(targetSlots) == 0 {
|
||||
return nil
|
||||
}
|
||||
return validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
|
||||
taskID: cloneScheduleTaskSlots(targetSlots),
|
||||
})
|
||||
}
|
||||
|
||||
// validateLocalOrderBatchPlacement 在“多任务同时变更”的假设下做顺序约束校验。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 先把所有候选落位一次性写入克隆态,再统一校验,避免 swap/batch/spread_even 出现伪冲突;
|
||||
// 2. 只校验 proposals 中涉及的任务,因为只要这些任务仍处于各自前驱/后继之间,就不会破坏同类整体顺序;
|
||||
// 3. 返回首个命中的中文错误,供写工具直接透传给 LLM。
|
||||
func validateLocalOrderBatchPlacement(state *ScheduleState, proposals map[int][]TaskSlot) error {
|
||||
if state == nil || len(proposals) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := state.Clone()
|
||||
for taskID, slots := range proposals {
|
||||
task := clone.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Errorf("顺序约束校验失败:任务ID %d 不存在", taskID)
|
||||
}
|
||||
task.Slots = cloneScheduleTaskSlots(slots)
|
||||
}
|
||||
|
||||
for taskID := range proposals {
|
||||
if err := validateTaskLocalOrderOnState(clone, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTaskLocalOrderOnState 判断某个任务在当前假设态下,是否仍处于同任务类前驱/后继之间。
|
||||
func validateTaskLocalOrderOnState(state *ScheduleState, taskID int) error {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Errorf("顺序约束校验失败:任务ID %d 不存在", taskID)
|
||||
}
|
||||
if !shouldEnforceTaskLocalOrder(*task) || len(task.Slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
prevTask, nextTask := findTaskClassNeighbors(state, *task)
|
||||
targetStartDay, targetStartSlot, _ := earliestScheduleTaskSlot(task.Slots)
|
||||
targetEndDay, _, targetEndSlot := latestScheduleTaskSlot(task.Slots)
|
||||
|
||||
if prevTask != nil && len(prevTask.Slots) > 0 {
|
||||
prevEndDay, _, prevEndSlot := latestScheduleTaskSlot(prevTask.Slots)
|
||||
if !isStrictlyAfter(targetStartDay, targetStartSlot, prevEndDay, prevEndSlot) {
|
||||
return fmt.Errorf(
|
||||
"顺序约束不满足:[%d]%s 不能放到%s。它必须晚于同任务类前一个任务 %s 的结束位置(%s)。",
|
||||
task.StateID,
|
||||
task.Name,
|
||||
formatTaskSlotsBriefWithState(state, task.Slots),
|
||||
formatTaskLabel(*prevTask),
|
||||
formatTaskSlotsBriefWithState(state, prevTask.Slots),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if nextTask != nil && len(nextTask.Slots) > 0 {
|
||||
nextStartDay, nextStartSlot, _ := earliestScheduleTaskSlot(nextTask.Slots)
|
||||
if !isStrictlyBefore(targetEndDay, targetEndSlot, nextStartDay, nextStartSlot) {
|
||||
return fmt.Errorf(
|
||||
"顺序约束不满足:[%d]%s 不能放到%s。它必须早于同任务类后一个任务 %s 的开始位置(%s)。",
|
||||
task.StateID,
|
||||
task.Name,
|
||||
formatTaskSlotsBriefWithState(state, task.Slots),
|
||||
formatTaskLabel(*nextTask),
|
||||
formatTaskSlotsBriefWithState(state, nextTask.Slots),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldEnforceTaskLocalOrder 判断任务是否需要参与“同任务类内部顺序”约束。
|
||||
func shouldEnforceTaskLocalOrder(task ScheduleTask) bool {
|
||||
return task.Source == "task_item" && task.TaskClassID > 0 && task.TaskOrder > 0
|
||||
}
|
||||
|
||||
// findTaskClassNeighbors 查找同任务类中 order 紧邻当前任务的前驱与后继。
|
||||
func findTaskClassNeighbors(state *ScheduleState, task ScheduleTask) (prevTask *ScheduleTask, nextTask *ScheduleTask) {
|
||||
if state == nil || !shouldEnforceTaskLocalOrder(task) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for i := range state.Tasks {
|
||||
candidate := &state.Tasks[i]
|
||||
if candidate.StateID == task.StateID {
|
||||
continue
|
||||
}
|
||||
if !shouldEnforceTaskLocalOrder(*candidate) {
|
||||
continue
|
||||
}
|
||||
if candidate.TaskClassID != task.TaskClassID {
|
||||
continue
|
||||
}
|
||||
|
||||
if candidate.TaskOrder < task.TaskOrder {
|
||||
if prevTask == nil || candidate.TaskOrder > prevTask.TaskOrder {
|
||||
prevTask = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
if candidate.TaskOrder > task.TaskOrder {
|
||||
if nextTask == nil || candidate.TaskOrder < nextTask.TaskOrder {
|
||||
nextTask = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return prevTask, nextTask
|
||||
}
|
||||
|
||||
func earliestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
|
||||
if len(slots) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
best := slots[0]
|
||||
for i := 1; i < len(slots); i++ {
|
||||
current := slots[i]
|
||||
if current.Day < best.Day ||
|
||||
(current.Day == best.Day && current.SlotStart < best.SlotStart) ||
|
||||
(current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd) {
|
||||
best = current
|
||||
}
|
||||
}
|
||||
return best.Day, best.SlotStart, best.SlotEnd
|
||||
}
|
||||
|
||||
func latestScheduleTaskSlot(slots []TaskSlot) (day int, slotStart int, slotEnd int) {
|
||||
if len(slots) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
best := slots[0]
|
||||
for i := 1; i < len(slots); i++ {
|
||||
current := slots[i]
|
||||
if current.Day > best.Day ||
|
||||
(current.Day == best.Day && current.SlotEnd > best.SlotEnd) ||
|
||||
(current.Day == best.Day && current.SlotEnd == best.SlotEnd && current.SlotStart > best.SlotStart) {
|
||||
best = current
|
||||
}
|
||||
}
|
||||
return best.Day, best.SlotStart, best.SlotEnd
|
||||
}
|
||||
|
||||
func isStrictlyAfter(dayA, slotA, dayB, slotB int) bool {
|
||||
if dayA != dayB {
|
||||
return dayA > dayB
|
||||
}
|
||||
return slotA > slotB
|
||||
}
|
||||
|
||||
func isStrictlyBefore(dayA, slotA, dayB, slotB int) bool {
|
||||
if dayA != dayB {
|
||||
return dayA < dayB
|
||||
}
|
||||
return slotA < slotB
|
||||
}
|
||||
|
||||
func cloneScheduleTaskSlots(src []TaskSlot) []TaskSlot {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]TaskSlot, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
@@ -380,7 +380,7 @@ func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
||||
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 默认 enqueue=true,让 LLM 优先走“逐项处理”而不是一次性批量组合;
|
||||
// 1. 默认保持纯读,不自动入队;只有显式 enqueue=true 时才进入队列链路;
|
||||
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
|
||||
// 3. 入队仅保存 task_id,不复制任务全文,避免队列状态膨胀。
|
||||
queueInfo := (*queryTargetQueueInfo)(nil)
|
||||
@@ -566,7 +566,7 @@ func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTa
|
||||
Limit: limit,
|
||||
TaskIDSet: intSliceToSet(taskIDs),
|
||||
Category: strings.TrimSpace(readStringAny(args, "category", "")),
|
||||
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
|
||||
Enqueue: readBoolAnyWithDefault(args, false, "enqueue"),
|
||||
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ func GetOverview(state *ScheduleState) string {
|
||||
}
|
||||
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
if len(tc.ExcludedDaysOfWeek) > 0 {
|
||||
parts := make([]string, len(tc.ExcludedDaysOfWeek))
|
||||
for i, d := range tc.ExcludedDaysOfWeek {
|
||||
parts[i] = fmt.Sprintf("%d", d)
|
||||
}
|
||||
line += fmt.Sprintf(" 排除星期=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,17 +20,25 @@ type TaskSlot struct {
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考。
|
||||
// 只记录影响排课决策的字段,不暴露数据库内部细节。
|
||||
// TaskClassMeta 是任务类级别的调度与认知画像元数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责向 LLM 暴露会影响粗排与主动优化判断的高价值字段;
|
||||
// 2. 不负责暴露数据库内部细节,也不承载 task_item 级别的数据;
|
||||
// 3. 这些字段会被 prompt、analyze_health、analyze_rhythm 共同消费,因此要保持轻量且稳定。
|
||||
type TaskClassMeta struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
|
||||
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
|
||||
StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD)
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
|
||||
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
|
||||
ExcludedDaysOfWeek []int `json:"excluded_days_of_week"` // 排除的星期几(1-7,空=无限制)
|
||||
StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD)
|
||||
SubjectType string `json:"subject_type,omitempty"` // "quantitative" | "memory" | "reading" | "mixed"
|
||||
DifficultyLevel string `json:"difficulty_level,omitempty"`
|
||||
CognitiveIntensity string `json:"cognitive_intensity,omitempty"`
|
||||
}
|
||||
|
||||
// ScheduleTask is a unified task representation in the tool state.
|
||||
@@ -51,6 +59,9 @@ type ScheduleTask struct {
|
||||
Duration int `json:"duration,omitempty"`
|
||||
// source=task_item only: TaskClass.ID,用于反查任务类约束。
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
// source=task_item only: 任务在所属任务类内的稳定顺序。
|
||||
// 该字段只用于写工具层的“同任务类内部顺序约束”,不直接暴露给 LLM 做决策。
|
||||
TaskOrder int `json:"task_order,omitempty"`
|
||||
// source=task_item only: TaskClass.ID for category lookup (internal alias).
|
||||
CategoryID int `json:"category_id,omitempty"`
|
||||
// source=event only: whether this slot allows embedding other tasks.
|
||||
@@ -68,7 +79,7 @@ type ScheduleTask struct {
|
||||
type ScheduleState struct {
|
||||
Window ScheduleWindow `json:"window"`
|
||||
Tasks []ScheduleTask `json:"tasks"`
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束与语义画像,供 LLM 排课参考
|
||||
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -56,6 +56,9 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||||
if err := validateSlotRange(slotStart, slotEnd); err != nil {
|
||||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||||
}
|
||||
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}); err != nil {
|
||||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 4. 冲突检测。
|
||||
conflict := findConflict(state, day, slotStart, slotEnd)
|
||||
@@ -136,6 +139,9 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||||
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
|
||||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||||
}
|
||||
if err := validateLocalOrderForSinglePlacement(state, taskID, []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}); err != nil {
|
||||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 5. 冲突检测(排除自身)。
|
||||
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
|
||||
@@ -213,6 +219,12 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||||
copy(oldSlotsA, taskA.Slots)
|
||||
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
|
||||
copy(oldSlotsB, taskB.Slots)
|
||||
if err := validateLocalOrderBatchPlacement(state, map[int][]TaskSlot{
|
||||
taskAID: cloneScheduleTaskSlots(oldSlotsB),
|
||||
taskBID: cloneScheduleTaskSlots(oldSlotsA),
|
||||
}); err != nil {
|
||||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||||
}
|
||||
|
||||
// 6. 交换 Slots。
|
||||
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
|
||||
@@ -303,6 +315,22 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||||
}
|
||||
}
|
||||
proposals := make(map[int][]TaskSlot, len(moves))
|
||||
for _, m := range moves {
|
||||
task := state.TaskByStateID(m.TaskID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
duration := taskDuration(*task)
|
||||
proposals[m.TaskID] = []TaskSlot{{
|
||||
Day: m.NewDay,
|
||||
SlotStart: m.NewSlotStart,
|
||||
SlotEnd: m.NewSlotStart + duration - 1,
|
||||
}}
|
||||
}
|
||||
if err := validateLocalOrderBatchPlacement(state, proposals); err != nil {
|
||||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s", err.Error())
|
||||
}
|
||||
|
||||
// 2. 克隆 state,在克隆上执行。
|
||||
clone := state.Clone()
|
||||
|
||||
494
backend/newAgent/tools/task_class_write.go
Normal file
494
backend/newAgent/tools/task_class_write.go
Normal file
@@ -0,0 +1,494 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
// TaskClassUpsertInput 描述任务类写库工具的标准化入参。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. ID=0 表示创建,ID>0 表示更新;
|
||||
// 2. Request 直接复用现有 UserAddTaskClassRequest 语义,避免多套字段定义漂移;
|
||||
// 3. Source 用于记录字段来源(chat/memory/web),不参与业务校验。
|
||||
type TaskClassUpsertInput struct {
|
||||
ID int
|
||||
Request model.UserAddTaskClassRequest
|
||||
Source string
|
||||
}
|
||||
|
||||
// TaskClassUpsertPersistResult 描述任务类写入持久层后的结果。
|
||||
type TaskClassUpsertPersistResult struct {
|
||||
TaskClassID int
|
||||
Created bool
|
||||
}
|
||||
|
||||
// TaskClassWriteDeps 描述任务类写库工具依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 工具层只负责参数标准化与结果包装,不直接依赖 DAO;
|
||||
// 2. UpsertTaskClass 由启动层注入,便于后续替换为 service/DAO 统一实现。
|
||||
type TaskClassWriteDeps struct {
|
||||
UpsertTaskClass func(userID int, input TaskClassUpsertInput) (TaskClassUpsertPersistResult, error)
|
||||
}
|
||||
|
||||
type taskClassValidationResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []string `json:"issues"`
|
||||
}
|
||||
|
||||
type taskClassUpsertToolResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
Created bool `json:"created,omitempty"`
|
||||
Validation taskClassValidationResult `json:"validation"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
|
||||
// NewTaskClassUpsertToolHandler 创建 upsert_task_class 工具 handler。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON;
|
||||
// 2. 不负责草案生成,草案由 prompt+LLM 完成;
|
||||
// 3. 不依赖 ScheduleState,可在纯聊天场景调用(execute 会注入 _user_id)。
|
||||
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
if deps.UpsertTaskClass == nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
|
||||
Error: "任务类写库依赖未注入",
|
||||
ErrorCode: "dependency_missing",
|
||||
})
|
||||
}
|
||||
|
||||
userID, ok := readUpsertUserID(args["_user_id"])
|
||||
if !ok || userID <= 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
|
||||
Error: "工具调用失败:无法识别用户身份",
|
||||
ErrorCode: "missing_user_id",
|
||||
})
|
||||
}
|
||||
|
||||
input, parseErr := parseTaskClassUpsertInput(args)
|
||||
if parseErr != nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
|
||||
Error: parseErr.Error(),
|
||||
ErrorCode: "invalid_args",
|
||||
})
|
||||
}
|
||||
|
||||
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
|
||||
if len(issues) > 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: issues},
|
||||
Error: strings.Join(issues, ";"),
|
||||
ErrorCode: "validation_failed",
|
||||
})
|
||||
}
|
||||
|
||||
result, err := deps.UpsertTaskClass(userID, input)
|
||||
if err != nil {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
|
||||
Error: err.Error(),
|
||||
ErrorCode: "persist_failed",
|
||||
})
|
||||
}
|
||||
if result.TaskClassID <= 0 {
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: false,
|
||||
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
|
||||
Error: "写入后未返回有效 task_class_id",
|
||||
ErrorCode: "invalid_persist_result",
|
||||
})
|
||||
}
|
||||
|
||||
return marshalTaskClassUpsertResult(taskClassUpsertToolResult{
|
||||
Tool: "upsert_task_class",
|
||||
Success: true,
|
||||
TaskClassID: result.TaskClassID,
|
||||
Created: result.Created,
|
||||
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
|
||||
Error: "",
|
||||
ErrorCode: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) {
|
||||
id := 0
|
||||
if rawID, exists := args["id"]; exists {
|
||||
parsedID, ok := readUpsertInt(rawID)
|
||||
if !ok {
|
||||
return TaskClassUpsertInput{}, fmt.Errorf("id 参数类型非法,必须为整数")
|
||||
}
|
||||
id = parsedID
|
||||
}
|
||||
|
||||
taskClassRaw, ok := args["task_class"]
|
||||
if !ok {
|
||||
return TaskClassUpsertInput{}, fmt.Errorf("缺少必填参数 task_class")
|
||||
}
|
||||
taskClassMap, ok := taskClassRaw.(map[string]any)
|
||||
if !ok {
|
||||
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数类型非法,必须是对象")
|
||||
}
|
||||
|
||||
// 允许顶层 items 覆盖 task_class.items,便于 LLM 在生成参数时拆分表达。
|
||||
if rawItems, exists := args["items"]; exists {
|
||||
taskClassMap["items"] = rawItems
|
||||
}
|
||||
normalizeTaskClassPayload(taskClassMap)
|
||||
|
||||
rawJSON, err := json.Marshal(taskClassMap)
|
||||
if err != nil {
|
||||
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数序列化失败")
|
||||
}
|
||||
|
||||
var request model.UserAddTaskClassRequest
|
||||
if err := json.Unmarshal(rawJSON, &request); err != nil {
|
||||
return TaskClassUpsertInput{}, fmt.Errorf("task_class 参数解析失败:%v", err)
|
||||
}
|
||||
|
||||
source := ""
|
||||
if rawSource, exists := args["source"]; exists {
|
||||
if text, ok := rawSource.(string); ok {
|
||||
source = strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
normalizeTaskClassSemanticRequest(&request)
|
||||
|
||||
return TaskClassUpsertInput{
|
||||
ID: id,
|
||||
Request: request,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeTaskClassPayload 对 LLM 常见“近义字段/错层字段”做轻量兼容归一化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把已知等价字段映射到后端真实契约,减少“明明填了却校验失败”的误伤;
|
||||
// 2. 不负责兜底补齐业务必填项(如 mode/config),这些仍由校验层决定是否报错;
|
||||
// 3. 仅处理本工具已观测到的高频偏差,避免过度“自动纠错”掩盖真实输入问题。
|
||||
func normalizeTaskClassPayload(taskClassMap map[string]any) {
|
||||
if len(taskClassMap) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 兼容日期字段错层:
|
||||
// 1.1 若顶层 start_date/end_date 缺失;
|
||||
// 1.2 且 config.start_date/config.end_date 有值;
|
||||
// 1.3 则抬升到顶层,匹配 UserAddTaskClassRequest 契约。
|
||||
configMap, _ := readAnyMap(taskClassMap["config"])
|
||||
promoteStringField(taskClassMap, configMap, "start_date")
|
||||
promoteStringField(taskClassMap, configMap, "end_date")
|
||||
|
||||
// 2. 兼容 items 的语义字段:
|
||||
// 2.1 content 缺失时,尝试从 description/title/name 回填;
|
||||
// 2.2 order 缺失或非法时,按当前顺序补 1..N;
|
||||
// 2.3 失败时不抛错,留给校验层输出明确问题。
|
||||
normalizeTaskClassItems(taskClassMap)
|
||||
}
|
||||
|
||||
func promoteStringField(top map[string]any, config map[string]any, key string) {
|
||||
if top == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(readAnyString(top[key])) != "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(readAnyString(config[key])) == "" {
|
||||
return
|
||||
}
|
||||
top[key] = strings.TrimSpace(readAnyString(config[key]))
|
||||
}
|
||||
|
||||
func normalizeTaskClassItems(taskClassMap map[string]any) {
|
||||
rawItems, exists := taskClassMap["items"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
itemList, ok := rawItems.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for idx := range itemList {
|
||||
itemMap, ok := itemList[idx].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasPositiveInt(itemMap["order"]) {
|
||||
itemMap["order"] = idx + 1
|
||||
}
|
||||
if strings.TrimSpace(readAnyString(itemMap["content"])) == "" {
|
||||
content := firstNonEmptyString(
|
||||
readAnyString(itemMap["content"]),
|
||||
readAnyString(itemMap["description"]),
|
||||
readAnyString(itemMap["title"]),
|
||||
readAnyString(itemMap["name"]),
|
||||
)
|
||||
if strings.TrimSpace(content) != "" {
|
||||
itemMap["content"] = strings.TrimSpace(content)
|
||||
}
|
||||
}
|
||||
itemList[idx] = itemMap
|
||||
}
|
||||
taskClassMap["items"] = itemList
|
||||
}
|
||||
|
||||
func readAnyMap(raw any) (map[string]any, bool) {
|
||||
if raw == nil {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := raw.(map[string]any)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func readAnyString(raw any) string {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeTaskClassSemanticRequest 归一化任务类语义画像字段。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 LLM 或用户可能给出的中文/近义值收口成稳定枚举;
|
||||
// 2. 不负责补默认值,字段缺失仍由上层决定是否接受;
|
||||
// 3. 归一化失败时保留原值,交给校验层输出明确错误。
|
||||
func normalizeTaskClassSemanticRequest(req *model.UserAddTaskClassRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
if normalized := normalizeSubjectType(req.SubjectType); normalized != "" {
|
||||
req.SubjectType = normalized
|
||||
}
|
||||
if normalized := normalizeLevelValue(req.DifficultyLevel); normalized != "" {
|
||||
req.DifficultyLevel = normalized
|
||||
}
|
||||
if normalized := normalizeLevelValue(req.CognitiveIntensity); normalized != "" {
|
||||
req.CognitiveIntensity = normalized
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSubjectType(raw string) string {
|
||||
value := strings.TrimSpace(strings.ToLower(raw))
|
||||
switch value {
|
||||
case "quantitative", "计算型", "计算", "理工", "理工型":
|
||||
return "quantitative"
|
||||
case "memory", "记忆型", "记忆", "背诵型", "背诵":
|
||||
return "memory"
|
||||
case "reading", "阅读型", "阅读":
|
||||
return "reading"
|
||||
case "mixed", "混合型", "混合":
|
||||
return "mixed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLevelValue(raw string) string {
|
||||
value := strings.TrimSpace(strings.ToLower(raw))
|
||||
switch value {
|
||||
case "low", "低":
|
||||
return "low"
|
||||
case "medium", "中", "中等":
|
||||
return "medium"
|
||||
case "high", "高":
|
||||
return "high"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasPositiveInt(raw any) bool {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return value > 0
|
||||
case int8:
|
||||
return value > 0
|
||||
case int16:
|
||||
return value > 0
|
||||
case int32:
|
||||
return value > 0
|
||||
case int64:
|
||||
return value > 0
|
||||
case float32:
|
||||
return int(value) > 0
|
||||
case float64:
|
||||
return int(value) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateTaskClassUpsertRequest(req model.UserAddTaskClassRequest, id int) []string {
|
||||
issues := make([]string, 0)
|
||||
if id < 0 {
|
||||
issues = append(issues, "id 不能小于 0")
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
issues = append(issues, "name 不能为空")
|
||||
}
|
||||
mode := strings.TrimSpace(strings.ToLower(req.Mode))
|
||||
if mode != "auto" && mode != "manual" {
|
||||
issues = append(issues, "mode 仅支持 auto/manual")
|
||||
}
|
||||
if mode == "auto" {
|
||||
if strings.TrimSpace(req.StartDate) == "" || strings.TrimSpace(req.EndDate) == "" {
|
||||
issues = append(issues, "auto 模式必须提供 start_date/end_date")
|
||||
} else {
|
||||
startDate, err1 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.StartDate), time.Local)
|
||||
endDate, err2 := time.ParseInLocation("2006-01-02", strings.TrimSpace(req.EndDate), time.Local)
|
||||
if err1 != nil || err2 != nil {
|
||||
issues = append(issues, "start_date/end_date 日期格式非法,需为 YYYY-MM-DD")
|
||||
} else if startDate.After(endDate) {
|
||||
issues = append(issues, "start_date 不能晚于 end_date")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.SubjectType) != "" && normalizeSubjectType(req.SubjectType) == "" {
|
||||
issues = append(issues, "subject_type 仅支持 quantitative/memory/reading/mixed")
|
||||
}
|
||||
if strings.TrimSpace(req.DifficultyLevel) != "" && normalizeLevelValue(req.DifficultyLevel) == "" {
|
||||
issues = append(issues, "difficulty_level 仅支持 low/medium/high")
|
||||
}
|
||||
if strings.TrimSpace(req.CognitiveIntensity) != "" && normalizeLevelValue(req.CognitiveIntensity) == "" {
|
||||
issues = append(issues, "cognitive_intensity 仅支持 low/medium/high")
|
||||
}
|
||||
if strings.TrimSpace(req.SubjectType) == "" {
|
||||
issues = append(issues, "subject_type 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.DifficultyLevel) == "" {
|
||||
issues = append(issues, "difficulty_level 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.CognitiveIntensity) == "" {
|
||||
issues = append(issues, "cognitive_intensity 不能为空")
|
||||
}
|
||||
if req.Config.TotalSlots <= 0 {
|
||||
issues = append(issues, "config.total_slots 必须大于 0")
|
||||
}
|
||||
strategy := strings.TrimSpace(strings.ToLower(req.Config.Strategy))
|
||||
if strategy != "steady" && strategy != "rapid" {
|
||||
issues = append(issues, "config.strategy 仅支持 steady/rapid")
|
||||
}
|
||||
for _, section := range req.Config.ExcludedSlots {
|
||||
// 1. excluded_slots 在粗排算法中按“半天块索引”解释,而不是原子节次;
|
||||
// 2. 每个块固定映射 2 节:1->1-2,2->3-4,...,6->11-12;
|
||||
// 3. 若放行 7~12,会在 buildTimeGrid 扩展时生成 13~24 节,触发数组越界。
|
||||
if section < 1 || section > 6 {
|
||||
issues = append(issues, "config.excluded_slots 仅允许 1~6(半天块索引,每块=2节)")
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, dayOfWeek := range req.Config.ExcludedDaysOfWeek {
|
||||
// 1. excluded_days_of_week 属于“整天不可排”的硬约束;
|
||||
// 2. 仅允许 1~7,对应周一到周日;
|
||||
// 3. 非法值会导致粗排过滤口径失真,因此统一在写入口拦截。
|
||||
if dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
issues = append(issues, "config.excluded_days_of_week 仅允许 1~7(周一到周日)")
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
issues = append(issues, "items 不能为空")
|
||||
}
|
||||
for index, item := range req.Items {
|
||||
if item.Order <= 0 {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].order 必须大于 0", index))
|
||||
}
|
||||
if strings.TrimSpace(item.Content) == "" {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].content 不能为空", index))
|
||||
}
|
||||
}
|
||||
return uniqueTaskClassIssues(issues)
|
||||
}
|
||||
|
||||
func uniqueTaskClassIssues(issues []string) []string {
|
||||
if len(issues) == 0 {
|
||||
return issues
|
||||
}
|
||||
seen := make(map[string]struct{}, len(issues))
|
||||
out := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
trimmed := strings.TrimSpace(issue)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readUpsertUserID(raw any) (int, bool) {
|
||||
return readUpsertInt(raw)
|
||||
}
|
||||
|
||||
func readUpsertInt(raw any) (int, bool) {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return value, true
|
||||
case int8:
|
||||
return int(value), true
|
||||
case int16:
|
||||
return int(value), true
|
||||
case int32:
|
||||
return int(value), true
|
||||
case int64:
|
||||
return int(value), true
|
||||
case float64:
|
||||
return int(value), true
|
||||
case float32:
|
||||
return int(value), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalTaskClassUpsertResult(result taskClassUpsertToolResult) string {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"upsert_task_class","success":false,"error":"result encode failed","error_code":"encode_failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
252
backend/newAgent/tools/tool_domain_map.go
Normal file
252
backend/newAgent/tools/tool_domain_map.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package newagenttools
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// ToolDomainSchedule 表示“排程调整”工具域。
|
||||
ToolDomainSchedule = "schedule"
|
||||
// ToolDomainTaskClass 表示“任务类定义”工具域。
|
||||
ToolDomainTaskClass = "taskclass"
|
||||
)
|
||||
|
||||
const (
|
||||
// ToolNameContextToolsAdd 表示“向 msg0 动态区注入目标工具域定义”工具。
|
||||
ToolNameContextToolsAdd = "context_tools_add"
|
||||
// ToolNameContextToolsRemove 表示“从 msg0 动态区移除目标工具域定义”工具。
|
||||
ToolNameContextToolsRemove = "context_tools_remove"
|
||||
)
|
||||
|
||||
const (
|
||||
// ToolPackCore 是固定包:始终注入,不允许显式 add/remove。
|
||||
ToolPackCore = "core"
|
||||
|
||||
// schedule 二级包(可选)。
|
||||
ToolPackQueue = "queue"
|
||||
ToolPackMutation = "mutation"
|
||||
ToolPackAnalyze = "analyze"
|
||||
ToolPackDetailRead = "detail_read"
|
||||
ToolPackDeepAnalyze = "deep_analyze"
|
||||
ToolPackWeb = "web"
|
||||
)
|
||||
|
||||
type toolProfile struct {
|
||||
Domain string
|
||||
Pack string
|
||||
}
|
||||
|
||||
// toolProfileByName 维护“工具名 -> 域/二级包”映射。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. context 管理工具不参与域/包映射;
|
||||
// 2. schedule 的 core 包是固定注入;其余能力按二级包按需注入;
|
||||
// 3. taskclass 目前只有 core 包(固定注入)。
|
||||
var toolProfileByName = map[string]toolProfile{
|
||||
"get_overview": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
|
||||
"query_available_slots": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
|
||||
"query_target_tasks": {Domain: ToolDomainSchedule, Pack: ToolPackCore},
|
||||
"analyze_health": {Domain: ToolDomainSchedule, Pack: ToolPackAnalyze},
|
||||
|
||||
"query_range": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
|
||||
"get_task_info": {Domain: ToolDomainSchedule, Pack: ToolPackDetailRead},
|
||||
|
||||
"queue_status": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
|
||||
"queue_pop_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
|
||||
"queue_apply_head_move": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
|
||||
"queue_skip_head": {Domain: ToolDomainSchedule, Pack: ToolPackQueue},
|
||||
|
||||
"place": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"swap": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"batch_move": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"spread_even": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"min_context_switch": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
"unplace": {Domain: ToolDomainSchedule, Pack: ToolPackMutation},
|
||||
|
||||
"analyze_rhythm": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
|
||||
"analyze_tolerance": {Domain: ToolDomainSchedule, Pack: ToolPackDeepAnalyze},
|
||||
|
||||
"web_search": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
|
||||
"web_fetch": {Domain: ToolDomainSchedule, Pack: ToolPackWeb},
|
||||
|
||||
"upsert_task_class": {Domain: ToolDomainTaskClass, Pack: ToolPackCore},
|
||||
}
|
||||
|
||||
// NormalizeToolDomain 统一规范化工具域字符串。
|
||||
func NormalizeToolDomain(domain string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case ToolDomainSchedule:
|
||||
return ToolDomainSchedule
|
||||
case ToolDomainTaskClass:
|
||||
return ToolDomainTaskClass
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupportedToolDomain 判断是否为当前支持的业务工具域。
|
||||
func IsSupportedToolDomain(domain string) bool {
|
||||
return NormalizeToolDomain(domain) != ""
|
||||
}
|
||||
|
||||
// NormalizeToolPack 统一规范化指定域下的二级包名。
|
||||
func NormalizeToolPack(domain, pack string) string {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
normalizedPack := strings.ToLower(strings.TrimSpace(pack))
|
||||
if normalizedDomain == "" || normalizedPack == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch normalizedDomain {
|
||||
case ToolDomainSchedule:
|
||||
switch normalizedPack {
|
||||
case ToolPackCore, ToolPackQueue, ToolPackMutation, ToolPackAnalyze, ToolPackDetailRead, ToolPackDeepAnalyze, ToolPackWeb:
|
||||
return normalizedPack
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
case ToolDomainTaskClass:
|
||||
if normalizedPack == ToolPackCore {
|
||||
return ToolPackCore
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupportedToolPack 判断某域下某二级包是否受支持。
|
||||
func IsSupportedToolPack(domain, pack string) bool {
|
||||
return NormalizeToolPack(domain, pack) != ""
|
||||
}
|
||||
|
||||
// IsFixedToolPack 判断某域下某二级包是否属于固定注入包。
|
||||
func IsFixedToolPack(domain, pack string) bool {
|
||||
normalizedPack := NormalizeToolPack(domain, pack)
|
||||
return normalizedPack == ToolPackCore
|
||||
}
|
||||
|
||||
// ListOptionalToolPacks 返回某域可选二级包列表(不含 core)。
|
||||
func ListOptionalToolPacks(domain string) []string {
|
||||
switch NormalizeToolDomain(domain) {
|
||||
case ToolDomainSchedule:
|
||||
return []string{
|
||||
ToolPackMutation,
|
||||
ToolPackAnalyze,
|
||||
ToolPackDetailRead,
|
||||
ToolPackDeepAnalyze,
|
||||
ToolPackQueue,
|
||||
ToolPackWeb,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ListDefaultToolPacks 返回某域“默认注入”的可选包集合。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 仅用于 packs 为空时的兜底,目的是降低 msg0 噪声;
|
||||
// 2. schedule 默认只开 mutation+analyze,其他包按需 add;
|
||||
// 3. taskclass 当前无可选包。
|
||||
func ListDefaultToolPacks(domain string) []string {
|
||||
switch NormalizeToolDomain(domain) {
|
||||
case ToolDomainSchedule:
|
||||
return []string{ToolPackMutation, ToolPackAnalyze}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeToolPacks 规范化 pack 列表,并去重。
|
||||
//
|
||||
// 1. 仅返回受支持的 pack;
|
||||
// 2. 自动剔除固定包 core(core 不接受显式管理);
|
||||
// 3. 顺序保持第一次出现顺序,便于日志和 prompt 可读性。
|
||||
func NormalizeToolPacks(domain string, packs []string) []string {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" || len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(packs))
|
||||
result := make([]string, 0, len(packs))
|
||||
for _, rawPack := range packs {
|
||||
pack := NormalizeToolPack(normalizedDomain, rawPack)
|
||||
if pack == "" || IsFixedToolPack(normalizedDomain, pack) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[pack]; exists {
|
||||
continue
|
||||
}
|
||||
seen[pack] = struct{}{}
|
||||
result = append(result, pack)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolveEffectiveToolPacks 返回某域下“真正生效”的可选包集合。
|
||||
//
|
||||
// 兼容策略:
|
||||
// 1. schedule 域且 packs 为空时,默认启用最小可用包(mutation+analyze);
|
||||
// 2. taskclass 目前无可选包,统一返回 nil;
|
||||
// 3. 非法域统一返回 nil。
|
||||
func ResolveEffectiveToolPacks(domain string, packs []string) []string {
|
||||
normalizedDomain := NormalizeToolDomain(domain)
|
||||
if normalizedDomain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if normalizedDomain == ToolDomainTaskClass {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedPacks := NormalizeToolPacks(normalizedDomain, packs)
|
||||
if len(normalizedPacks) > 0 {
|
||||
return normalizedPacks
|
||||
}
|
||||
|
||||
defaultPacks := ListDefaultToolPacks(normalizedDomain)
|
||||
if len(defaultPacks) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(defaultPacks))
|
||||
copy(result, defaultPacks)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsContextManagementTool 判断工具是否属于上下文管理工具。
|
||||
func IsContextManagementTool(name string) bool {
|
||||
switch strings.TrimSpace(name) {
|
||||
case ToolNameContextToolsAdd, ToolNameContextToolsRemove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveToolDomain 返回工具所属业务域。
|
||||
func ResolveToolDomain(name string) (string, bool) {
|
||||
domain, _, ok := ResolveToolDomainPack(name)
|
||||
return domain, ok
|
||||
}
|
||||
|
||||
// ResolveToolDomainPack 返回工具所属域与二级包。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 命中映射返回 (domain, pack, true);
|
||||
// 2. 未命中返回 ("", "", false);
|
||||
// 3. context 管理工具统一返回 ("", "", false)。
|
||||
func ResolveToolDomainPack(name string) (string, string, bool) {
|
||||
toolName := strings.TrimSpace(name)
|
||||
if IsContextManagementTool(toolName) {
|
||||
return "", "", false
|
||||
}
|
||||
profile, ok := toolProfileByName[toolName]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return profile.Domain, profile.Pack, true
|
||||
}
|
||||
Reference in New Issue
Block a user