后端: 1. 工具结果结构化切流继续推进:schedule 读工具改为“父包 adapter + 子包 view builder”,`queue_pop_head` / `queue_skip_head` 脱离 legacy wrapper,`analyze_health` / `analyze_rhythm` 补齐 `schedule.analysis_result` 诊断卡片。 2. 非 schedule 工具补齐专属结果协议:`web_search` / `web_fetch`、`upsert_task_class`、`context_tools_add` / `context_tools_remove` 全部接入结构化 `ResultView`,注册表继续去 legacy wrapper,同时保持原始 `ObservationText` 供模型链路复用。 3. 工具展示细节继续收口:参数本地化补齐 `domain` / `packs` / `mode` / `all`,deliver 阶段补发段落分隔,避免 execute 与总结正文黏连。 前端: 4. `ToolCardRenderer` 升级为多协议通用渲染器,补齐 read / analysis / web / taskclass / context 卡片渲染、参数折叠区、未知协议兜底与操作明细展示。 5. `AssistantPanel` 修正 `tool_result` 结果回填与卡片布局宽度问题,并新增结构化卡片 fixture / mock 调试入口,便于整体验收。 仓库: 6. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
409 lines
12 KiB
Go
409 lines
12 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
)
|
||
|
||
// 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"`
|
||
}
|
||
|
||
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)
|
||
}
|