后端: 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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
459 lines
15 KiB
Go
459 lines
15 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||
taskclassresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/taskclass_result"
|
||
)
|
||
|
||
type taskClassUpsertExecutionInput struct {
|
||
Result taskClassUpsertToolResult
|
||
Normalized *TaskClassUpsertInput
|
||
}
|
||
|
||
// NewTaskClassUpsertToolHandler 返回 upsert_task_class 的结构化结果 handler。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只负责参数解析、校验、调用依赖与包装结构化结果;
|
||
// 2. 不改变既有写库语义、confirm 语义与 observation JSON 合约;
|
||
// 3. 老实现暂以 legacy 函数保留,便于本轮并行迁移后回溯与对照。
|
||
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
|
||
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
|
||
_ = state
|
||
|
||
if deps.UpsertTaskClass == nil {
|
||
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: 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 buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: taskClassUpsertToolResult{
|
||
Tool: "upsert_task_class",
|
||
Success: false,
|
||
Validation: taskClassValidationResult{OK: false, Issues: issues},
|
||
Error: strings.Join(issues, ";"),
|
||
ErrorCode: "validation_failed",
|
||
},
|
||
Normalized: &input,
|
||
})
|
||
}
|
||
|
||
result, err := deps.UpsertTaskClass(userID, input)
|
||
if err != nil {
|
||
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: taskClassUpsertToolResult{
|
||
Tool: "upsert_task_class",
|
||
Success: false,
|
||
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
|
||
Error: err.Error(),
|
||
ErrorCode: "persist_failed",
|
||
},
|
||
Normalized: &input,
|
||
})
|
||
}
|
||
if result.TaskClassID <= 0 {
|
||
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: taskClassUpsertToolResult{
|
||
Tool: "upsert_task_class",
|
||
Success: false,
|
||
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
|
||
Error: "写入后未返回有效 task_class_id",
|
||
ErrorCode: "invalid_persist_result",
|
||
},
|
||
Normalized: &input,
|
||
})
|
||
}
|
||
|
||
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
|
||
Result: taskClassUpsertToolResult{
|
||
Tool: "upsert_task_class",
|
||
Success: true,
|
||
TaskClassID: result.TaskClassID,
|
||
Created: result.Created,
|
||
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
|
||
Error: "",
|
||
ErrorCode: "",
|
||
},
|
||
Normalized: &input,
|
||
})
|
||
}
|
||
}
|
||
|
||
// buildTaskClassUpsertExecutionResult 负责把 upsert_task_class 的原始 observation 包装成结构化卡片。
|
||
//
|
||
// 步骤化说明:
|
||
// 1. 先沿用 LegacyResult 保留 observation、参数预览、错误码提取等既有链路能力;
|
||
// 2. 再把规范化请求摘要和写入结果投影到 taskclass_result 子包,避免展示层反向依赖父包;
|
||
// 3. 最后只替换 ResultView / Summary,不改写写库语义、confirm 语义和原始错误文本。
|
||
func buildTaskClassUpsertExecutionResult(
|
||
args map[string]any,
|
||
input taskClassUpsertExecutionInput,
|
||
) ToolExecutionResult {
|
||
observation := marshalTaskClassUpsertResult(input.Result)
|
||
legacy := LegacyResult("upsert_task_class", args, observation)
|
||
|
||
requestSummary := buildTaskClassRequestSummary(args, input.Normalized)
|
||
view := taskclassresult.BuildUpsertTaskClassView(taskclassresult.BuildUpsertTaskClassViewInput{
|
||
Status: legacy.Status,
|
||
Observation: observation,
|
||
Result: taskclassresult.UpsertResult{
|
||
Tool: strings.TrimSpace(input.Result.Tool),
|
||
Success: input.Result.Success,
|
||
TaskClassID: input.Result.TaskClassID,
|
||
Created: input.Result.Created,
|
||
Error: strings.TrimSpace(input.Result.Error),
|
||
ErrorCode: strings.TrimSpace(input.Result.ErrorCode),
|
||
ValidationOK: input.Result.Validation.OK,
|
||
ValidationIssues: append([]string(nil), input.Result.Validation.Issues...),
|
||
},
|
||
Request: requestSummary,
|
||
MachinePayload: buildTaskClassMachinePayload(input.Result, requestSummary),
|
||
})
|
||
return buildTaskClassWriteExecutionResult(legacy, args, view)
|
||
}
|
||
|
||
// buildTaskClassWriteExecutionResult 负责把子包纯展示视图包回父包统一协议。
|
||
func buildTaskClassWriteExecutionResult(
|
||
legacy ToolExecutionResult,
|
||
args map[string]any,
|
||
view taskclassresult.WriteResultView,
|
||
) ToolExecutionResult {
|
||
result := legacy
|
||
status := normalizeToolStatus(result.Status)
|
||
if status == "" {
|
||
status = ToolStatusDone
|
||
}
|
||
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
|
||
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
|
||
status = normalized
|
||
}
|
||
}
|
||
|
||
collapsed := cloneAnyMap(view.Collapsed)
|
||
if collapsed == nil {
|
||
collapsed = make(map[string]any)
|
||
}
|
||
expanded := cloneAnyMap(view.Expanded)
|
||
if expanded == nil {
|
||
expanded = make(map[string]any)
|
||
}
|
||
|
||
collapsed["status"] = status
|
||
if _, exists := collapsed["status_label"]; !exists {
|
||
collapsed["status_label"] = resolveToolStatusLabelCN(status)
|
||
}
|
||
if _, exists := expanded["raw_text"]; !exists {
|
||
expanded["raw_text"] = result.ObservationText
|
||
}
|
||
|
||
viewType := strings.TrimSpace(view.ViewType)
|
||
if viewType == "" {
|
||
viewType = taskclassresult.ViewTypeWriteResult
|
||
}
|
||
version := view.Version
|
||
if version <= 0 {
|
||
version = taskclassresult.ViewVersionWriteResult
|
||
}
|
||
|
||
result.Status = status
|
||
result.Success = status == ToolStatusDone
|
||
result.ResultView = &ToolDisplayView{
|
||
ViewType: viewType,
|
||
Version: version,
|
||
Collapsed: collapsed,
|
||
Expanded: expanded,
|
||
}
|
||
if title, ok := readStringAnyMap(collapsed, "title"); ok {
|
||
result.Summary = title
|
||
}
|
||
if !result.Success {
|
||
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
|
||
if strings.TrimSpace(result.ErrorCode) == "" {
|
||
result.ErrorCode = strings.TrimSpace(errorCode)
|
||
}
|
||
if strings.TrimSpace(result.ErrorMessage) == "" {
|
||
result.ErrorMessage = strings.TrimSpace(errorMessage)
|
||
}
|
||
}
|
||
return EnsureToolResultDefaults(result, args)
|
||
}
|
||
|
||
func buildTaskClassRequestSummary(
|
||
args map[string]any,
|
||
normalized *TaskClassUpsertInput,
|
||
) taskclassresult.RequestSummary {
|
||
summary := buildTaskClassRequestSummaryFromArgs(args)
|
||
if normalized == nil {
|
||
return summary
|
||
}
|
||
|
||
summary.RequestedID = normalized.ID
|
||
summary.Name = strings.TrimSpace(normalized.Request.Name)
|
||
summary.Mode = strings.TrimSpace(normalized.Request.Mode)
|
||
summary.StartDate = strings.TrimSpace(normalized.Request.StartDate)
|
||
summary.EndDate = strings.TrimSpace(normalized.Request.EndDate)
|
||
summary.SubjectType = strings.TrimSpace(normalized.Request.SubjectType)
|
||
summary.DifficultyLevel = strings.TrimSpace(normalized.Request.DifficultyLevel)
|
||
summary.CognitiveIntensity = strings.TrimSpace(normalized.Request.CognitiveIntensity)
|
||
summary.TotalSlots = normalized.Request.Config.TotalSlots
|
||
summary.AllowFillerCourse = normalized.Request.Config.AllowFillerCourse
|
||
summary.Strategy = strings.TrimSpace(normalized.Request.Config.Strategy)
|
||
summary.ExcludedSlots = cloneIntSlice(normalized.Request.Config.ExcludedSlots)
|
||
summary.ExcludedDaysOfWeek = cloneIntSlice(normalized.Request.Config.ExcludedDaysOfWeek)
|
||
summary.Source = strings.TrimSpace(normalized.Source)
|
||
summary.Items = buildTaskClassItemsSummary(normalized.Request.Items)
|
||
return summary
|
||
}
|
||
|
||
func buildTaskClassRequestSummaryFromArgs(args map[string]any) taskclassresult.RequestSummary {
|
||
summary := taskclassresult.RequestSummary{
|
||
RequestedID: readOptionalInt(args, "id"),
|
||
Source: readOptionalString(args, "source"),
|
||
Items: make([]taskclassresult.TaskClassItemSummary, 0),
|
||
ExcludedSlots: make([]int, 0),
|
||
ExcludedDaysOfWeek: make([]int, 0),
|
||
}
|
||
|
||
taskClassMap, _ := readAnyMap(args["task_class"])
|
||
if taskClassMap == nil {
|
||
return summary
|
||
}
|
||
|
||
summary.Name = strings.TrimSpace(readAnyString(taskClassMap["name"]))
|
||
summary.Mode = strings.TrimSpace(readAnyString(taskClassMap["mode"]))
|
||
summary.StartDate = strings.TrimSpace(readAnyString(taskClassMap["start_date"]))
|
||
summary.EndDate = strings.TrimSpace(readAnyString(taskClassMap["end_date"]))
|
||
summary.SubjectType = strings.TrimSpace(readAnyString(taskClassMap["subject_type"]))
|
||
summary.DifficultyLevel = strings.TrimSpace(readAnyString(taskClassMap["difficulty_level"]))
|
||
summary.CognitiveIntensity = strings.TrimSpace(readAnyString(taskClassMap["cognitive_intensity"]))
|
||
|
||
configMap, _ := readAnyMap(taskClassMap["config"])
|
||
summary.TotalSlots = readAnyInt(configMap["total_slots"])
|
||
summary.AllowFillerCourse = readAnyBool(configMap["allow_filler_course"])
|
||
summary.Strategy = strings.TrimSpace(readAnyString(configMap["strategy"]))
|
||
summary.ExcludedSlots = readAnyIntSlice(configMap["excluded_slots"])
|
||
summary.ExcludedDaysOfWeek = readAnyIntSlice(configMap["excluded_days_of_week"])
|
||
|
||
rawItems := taskClassMap["items"]
|
||
if topLevelItems, exists := args["items"]; exists {
|
||
rawItems = topLevelItems
|
||
}
|
||
summary.Items = buildTaskClassItemsSummaryFromRaw(rawItems)
|
||
return summary
|
||
}
|
||
|
||
func buildTaskClassItemsSummary(items []model.UserAddTaskClassItemRequest) []taskclassresult.TaskClassItemSummary {
|
||
if len(items) == 0 {
|
||
return make([]taskclassresult.TaskClassItemSummary, 0)
|
||
}
|
||
out := make([]taskclassresult.TaskClassItemSummary, 0, len(items))
|
||
for _, item := range items {
|
||
summary := taskclassresult.TaskClassItemSummary{
|
||
ID: item.ID,
|
||
Order: item.Order,
|
||
Content: strings.TrimSpace(item.Content),
|
||
}
|
||
if item.EmbeddedTime != nil {
|
||
summary.EmbeddedWeek = item.EmbeddedTime.Week
|
||
summary.EmbeddedDay = item.EmbeddedTime.DayOfWeek
|
||
summary.EmbeddedSectionFrom = item.EmbeddedTime.SectionFrom
|
||
summary.EmbeddedSectionTo = item.EmbeddedTime.SectionTo
|
||
}
|
||
out = append(out, summary)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func buildTaskClassItemsSummaryFromRaw(raw any) []taskclassresult.TaskClassItemSummary {
|
||
rawList, ok := raw.([]any)
|
||
if !ok || len(rawList) == 0 {
|
||
return make([]taskclassresult.TaskClassItemSummary, 0)
|
||
}
|
||
out := make([]taskclassresult.TaskClassItemSummary, 0, len(rawList))
|
||
for index, row := range rawList {
|
||
itemMap, ok := row.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
summary := taskclassresult.TaskClassItemSummary{
|
||
ID: readAnyInt(itemMap["id"]),
|
||
Order: maxPositiveInt(readAnyInt(itemMap["order"]), index+1),
|
||
Content: strings.TrimSpace(firstNonEmptyString(
|
||
readAnyString(itemMap["content"]),
|
||
readAnyString(itemMap["description"]),
|
||
readAnyString(itemMap["title"]),
|
||
readAnyString(itemMap["name"]),
|
||
)),
|
||
}
|
||
if embeddedMap, ok := readAnyMap(itemMap["embedded_time"]); ok {
|
||
summary.EmbeddedWeek = readAnyInt(embeddedMap["week"])
|
||
summary.EmbeddedDay = readAnyInt(embeddedMap["day_of_week"])
|
||
summary.EmbeddedSectionFrom = readAnyInt(embeddedMap["section_from"])
|
||
summary.EmbeddedSectionTo = readAnyInt(embeddedMap["section_to"])
|
||
}
|
||
out = append(out, summary)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func buildTaskClassMachinePayload(
|
||
result taskClassUpsertToolResult,
|
||
request taskclassresult.RequestSummary,
|
||
) map[string]any {
|
||
return map[string]any{
|
||
"parsed_result": map[string]any{
|
||
"tool": strings.TrimSpace(result.Tool),
|
||
"success": result.Success,
|
||
"task_class_id": result.TaskClassID,
|
||
"created": result.Created,
|
||
"validation": map[string]any{
|
||
"ok": result.Validation.OK,
|
||
"issues": append([]string(nil), result.Validation.Issues...),
|
||
},
|
||
"error": strings.TrimSpace(result.Error),
|
||
"error_code": strings.TrimSpace(result.ErrorCode),
|
||
},
|
||
"input_summary": map[string]any{
|
||
"requested_id": request.RequestedID,
|
||
"name": request.Name,
|
||
"mode": request.Mode,
|
||
"start_date": request.StartDate,
|
||
"end_date": request.EndDate,
|
||
"subject_type": request.SubjectType,
|
||
"difficulty_level": request.DifficultyLevel,
|
||
"cognitive_intensity": request.CognitiveIntensity,
|
||
"total_slots": request.TotalSlots,
|
||
"allow_filler_course": request.AllowFillerCourse,
|
||
"strategy": request.Strategy,
|
||
"excluded_slots": cloneIntSlice(request.ExcludedSlots),
|
||
"excluded_days_of_week": cloneIntSlice(request.ExcludedDaysOfWeek),
|
||
"source": request.Source,
|
||
"items": buildTaskClassItemMachinePayload(request.Items),
|
||
},
|
||
}
|
||
}
|
||
|
||
func buildTaskClassItemMachinePayload(items []taskclassresult.TaskClassItemSummary) []map[string]any {
|
||
if len(items) == 0 {
|
||
return make([]map[string]any, 0)
|
||
}
|
||
out := make([]map[string]any, 0, len(items))
|
||
for _, item := range items {
|
||
out = append(out, map[string]any{
|
||
"id": item.ID,
|
||
"order": item.Order,
|
||
"content": item.Content,
|
||
"embedded_week": item.EmbeddedWeek,
|
||
"embedded_day": item.EmbeddedDay,
|
||
"section_from": item.EmbeddedSectionFrom,
|
||
"section_to": item.EmbeddedSectionTo,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func readOptionalString(args map[string]any, key string) string {
|
||
if len(args) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(readAnyString(args[key]))
|
||
}
|
||
|
||
func readOptionalInt(args map[string]any, key string) int {
|
||
if len(args) == 0 {
|
||
return 0
|
||
}
|
||
return readAnyInt(args[key])
|
||
}
|
||
|
||
func readAnyInt(raw any) int {
|
||
value, ok := readUpsertInt(raw)
|
||
if !ok {
|
||
return 0
|
||
}
|
||
return value
|
||
}
|
||
|
||
func readAnyBool(raw any) bool {
|
||
value, ok := raw.(bool)
|
||
return ok && value
|
||
}
|
||
|
||
func readAnyIntSlice(raw any) []int {
|
||
switch typed := raw.(type) {
|
||
case []int:
|
||
return cloneIntSlice(typed)
|
||
case []any:
|
||
out := make([]int, 0, len(typed))
|
||
for _, item := range typed {
|
||
value, ok := readUpsertInt(item)
|
||
if !ok {
|
||
continue
|
||
}
|
||
out = append(out, value)
|
||
}
|
||
return out
|
||
default:
|
||
return make([]int, 0)
|
||
}
|
||
}
|
||
|
||
func cloneIntSlice(values []int) []int {
|
||
if len(values) == 0 {
|
||
return make([]int, 0)
|
||
}
|
||
out := make([]int, len(values))
|
||
copy(out, values)
|
||
return out
|
||
}
|
||
|
||
func maxPositiveInt(left int, right int) int {
|
||
if left <= 0 {
|
||
return right
|
||
}
|
||
if right <= 0 {
|
||
return left
|
||
}
|
||
if left > right {
|
||
return left
|
||
}
|
||
return right
|
||
}
|