Version: 0.9.52.dev.260428

后端:
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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
This commit is contained in:
Losita
2026-04-28 20:22:22 +08:00
parent 1a5b2ecd73
commit d89e2830a9
38 changed files with 9180 additions and 1577 deletions

View File

@@ -0,0 +1,517 @@
package schedule_analysis
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// BuildResultView 统一封装 schedule.analysis_result 结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图;
// 2. 负责在子包内补齐 status / status_label避免依赖父包常量
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(input.Title),
"subtitle": strings.TrimSpace(input.Subtitle),
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": metricListToMaps(input.Metrics),
}
expanded := map[string]any{
"items": itemListToMaps(input.Items),
"sections": cloneSectionList(input.Sections),
"raw_text": input.Observation,
}
if len(input.MachinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(input.MachinePayload)
}
return AnalysisResultView{
ViewType: ViewTypeAnalysisResult,
Version: ViewVersionAnalysisResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
// BuildFailureView 统一生成 analysis 工具失败卡片视图。
//
// 职责边界:
// 1. 只从 observation 中提炼失败文案和参数回显;
// 2. 不负责判断失败条件,调用方需要先确认 observation 失败;
// 3. raw_text 仍保留原始 observation方便 debug 与下游排查。
func BuildFailureView(input BuildFailureViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusFailed
}
title := strings.TrimSpace(input.Title)
if title == "" {
title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName))
}
subtitle := strings.TrimSpace(input.Subtitle)
if subtitle == "" {
subtitle = failureText(input.Observation, "诊断分析失败,请检查当前日程状态后重试。")
}
sections := []map[string]any{
BuildCalloutSection("执行失败", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: status,
Title: title,
Subtitle: subtitle,
Sections: sections,
Observation: input.Observation,
})
}
func BuildMetric(label string, value string) MetricField {
return MetricField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildKVField(label string, value string) KVField {
return KVField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
// BuildArgsSection 把父包已经本地化的参数字段拼成展示 section。
//
// 职责边界:
// 1. 只接受纯 KVField不依赖父包 ToolArgumentView
// 2. 不解释 detail / threshold / hard_categories 是否真实参与计算;
// 3. 没有有效字段时返回 nil避免空 section 干扰前端。
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
if strings.TrimSpace(field.Label) == "" || strings.TrimSpace(field.Value) == "" {
continue
}
valid = append(valid, BuildKVField(field.Label, field.Value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func parseObservationJSON(observation string) (map[string]any, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func isSuccessPayload(payload map[string]any) bool {
success, ok := readBool(payload, "success")
return ok && success
}
func failureText(observation string, fallback string) string {
if payload, ok := parseObservationJSON(observation); ok {
if message := firstString(payload, "error", "message", "reason", "err"); message != "" {
return message
}
}
if strings.TrimSpace(observation) != "" {
return strings.TrimSpace(observation)
}
return strings.TrimSpace(fallback)
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
switch strings.TrimSpace(toolName) {
case "analyze_health":
return "综合体检"
case "analyze_rhythm":
return "学习节律分析"
default:
return "诊断分析"
}
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{"label": label, "value": value})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []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 {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
default:
return typed
}
}
func readMap(input map[string]any, key string) map[string]any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
row, _ := value.(map[string]any)
return row
}
func readList(input map[string]any, key string) []any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
rows, _ := value.([]any)
return rows
}
func readString(input map[string]any, key string) string {
if len(input) == 0 {
return ""
}
value, ok := input[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
if text == "<nil>" {
return ""
}
return text
}
}
func firstString(input map[string]any, keys ...string) string {
for _, key := range keys {
if value := readString(input, key); value != "" {
return value
}
}
return ""
}
func readBool(input map[string]any, key string) (bool, bool) {
if len(input) == 0 {
return false, false
}
value, ok := input[key]
if !ok {
return false, false
}
typed, ok := value.(bool)
return typed, ok
}
func readInt(input map[string]any, key string) int {
value := readFloat(input, key)
return int(value)
}
func readFloat(input map[string]any, key string) float64 {
if len(input) == 0 {
return 0
}
value, ok := input[key]
if !ok || value == nil {
return 0
}
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
default:
return 0
}
}
func severityRank(severity string) int {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return 0
case "warning":
return 1
default:
return 2
}
}
func formatSeverityCN(severity string) string {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return "高风险"
case "warning":
return "需关注"
default:
return "提示"
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatFloat(value float64) string {
return fmt.Sprintf("%.2f", value)
}
func formatPercent(value float64) string {
return fmt.Sprintf("%.0f%%", value*100)
}
func formatOperationCN(operation string) string {
switch strings.TrimSpace(operation) {
case "move":
return "移动"
case "swap":
return "交换"
case "close":
return "收口"
case "ask_user":
return "询问用户"
default:
if strings.TrimSpace(operation) == "" {
return "未指定"
}
return strings.TrimSpace(operation)
}
}
func formatEffectCN(effect string) string {
switch strings.TrimSpace(effect) {
case "improve":
return "明显改善"
case "partial_improve":
return "部分改善"
case "shift":
return "问题转移"
case "no_gain":
return "收益不足"
case "regress":
return "变差"
case "close":
return "收口"
default:
if strings.TrimSpace(effect) == "" {
return "未标注"
}
return strings.TrimSpace(effect)
}
}
func sortedKeys(input map[string]any) []string {
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(raw)
}

View File

@@ -0,0 +1,267 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeHealthView 把 analyze_health 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只解析 observation 的现有 JSON 字段,不改变字段名、层级或内容;
// 2. 展示层优先读取 feasibility / decision / metrics避免依赖自然语言摘要
// 3. 解析失败或 success=false 时返回失败卡片raw_text 仍保留原始 observation。
func BuildAnalyzeHealthView(input AnalyzeHealthViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_health",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
rhythm := readMap(metricsMap, "rhythm")
tightness := readMap(metricsMap, "tightness")
profile := readMap(metricsMap, "profile")
feasibility := readMap(payload, "feasibility")
decision := readMap(payload, "decision")
title := buildHealthTitle(feasibility, decision)
subtitle := buildHealthSubtitle(feasibility, decision)
metrics := buildHealthMetrics(rhythm, tightness, profile, feasibility)
candidateItems := buildHealthCandidateItems(decision)
issueItems := buildIssueItems(readList(payload, "issues"))
sections := []map[string]any{
BuildKVSection("裁决结论", buildHealthDecisionFields(feasibility, decision, metricsMap)),
BuildKVSection("关键指标", buildHealthMetricFields(rhythm, tightness, profile, metricsMap)),
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
} else {
sections = append(sections, BuildCalloutSection("问题清单", "当前没有结构化问题项。", "info", nil))
}
if len(candidateItems) > 0 {
sections = append(sections, BuildItemsSection("候选操作", candidateItems))
} else {
sections = append(sections, BuildCalloutSection("候选操作", "当前没有可执行候选。", "info", nil))
}
sections = append(sections, buildHealthNextStepSection(feasibility, decision, candidateItems))
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: subtitle,
Metrics: metrics,
Items: candidateItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildHealthTitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return "综合体检:当前约束不可行"
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return "综合体检:建议继续微调"
}
return "综合体检:可以收口"
}
func buildHealthSubtitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
gap := readInt(feasibility, "capacity_gap")
reason := readString(feasibility, "reason_code")
if reason == "" {
reason = "capacity_insufficient"
}
return fmt.Sprintf("容量仍缺 %d 节,原因:%s。", gap, reason)
}
if problem := readString(decision, "primary_problem"); problem != "" {
return problem
}
return "当前没有发现需要继续处理的结构化问题。"
}
func buildHealthMetrics(rhythm, tightness, profile, feasibility map[string]any) []MetricField {
metrics := []MetricField{
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(rhythm, "heavy_adjacent_days"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(rhythm, "max_switch_count"))),
BuildMetric("可局部移动", fmt.Sprintf("%d 项", readInt(tightness, "locally_movable_task_count"))),
BuildMetric("紧度", fallbackLabel(readString(tightness, "tightness_level"), "未标注")),
}
if gap := readInt(feasibility, "capacity_gap"); gap > 0 {
metrics = append(metrics, BuildMetric("容量缺口", fmt.Sprintf("%d 节", gap)))
return metrics
}
if missing := readInt(profile, "missing_complete_profile_count"); missing > 0 {
metrics = append(metrics, BuildMetric("画像缺失", fmt.Sprintf("%d 门", missing)))
}
return metrics
}
func buildHealthDecisionFields(feasibility map[string]any, decision map[string]any, metrics map[string]any) []KVField {
shouldContinue, _ := readBool(decision, "should_continue_optimize")
forced, _ := readBool(decision, "is_forced_imperfection")
canClose, _ := readBool(metrics, "can_close")
feasible, feasibleOK := readBool(feasibility, "is_feasible")
feasibleText := "未返回"
if feasibleOK {
feasibleText = formatBoolCN(feasible)
}
return []KVField{
BuildKVField("是否继续优化", formatBoolCN(shouldContinue)),
BuildKVField("当前可收口", formatBoolCN(canClose)),
BuildKVField("推荐动作", formatOperationCN(readString(decision, "recommended_operation"))),
BuildKVField("主问题", fallbackLabel(readString(decision, "primary_problem"), "当前没有发现值得继续处理的局部认知问题")),
BuildKVField("约束代价", formatBoolCN(forced)),
BuildKVField("约束可行", feasibleText),
BuildKVField("容量缺口", fmt.Sprintf("%d 节", readInt(feasibility, "capacity_gap"))),
BuildKVField("可行性原因", fallbackLabel(readString(feasibility, "reason_code"), "未返回")),
}
}
func buildHealthMetricFields(rhythm, tightness, profile, metrics map[string]any) []KVField {
canClose, _ := readBool(metrics, "can_close")
return []KVField{
BuildKVField("认知块平衡", fmt.Sprintf("%d", readInt(rhythm, "block_balance"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(rhythm, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(rhythm, "compressed_run_count"))),
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(rhythm, "avg_switches_per_day"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(rhythm, "same_type_transition_ratio"))),
BuildKVField("局部候选均值", fmt.Sprintf("%.1f 个", readFloat(tightness, "avg_local_alternative_slots"))),
BuildKVField("跨任务类交换机会", fmt.Sprintf("%d 个", readInt(tightness, "cross_class_swap_options"))),
BuildKVField("被迫高认知相邻", fmt.Sprintf("%d 天", readInt(tightness, "forced_heavy_adjacent_days"))),
BuildKVField("语义画像缺失", fmt.Sprintf("%d 门", readInt(profile, "missing_complete_profile_count"))),
BuildKVField("当前可收口", formatBoolCN(canClose)),
}
}
func buildHealthCandidateItems(decision map[string]any) []ItemView {
candidates := readList(decision, "candidates")
if len(candidates) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(candidates))
for _, raw := range candidates {
candidate, ok := raw.(map[string]any)
if !ok {
continue
}
after := readMap(candidate, "after")
canClose, _ := readBool(after, "can_close")
tool := readString(candidate, "tool")
effect := readString(candidate, "effect")
title := readString(candidate, "summary")
if title == "" {
title = fallbackLabel(readString(candidate, "candidate_id"), "候选操作")
}
subtitle := readString(after, "primary_problem")
if subtitle == "" {
subtitle = fmt.Sprintf("效果:%s", formatEffectCN(effect))
}
tags := []string{formatOperationCN(tool), formatEffectCN(effect)}
if canClose {
tags = append(tags, "执行后可收口")
}
detailLines := []string{
"候选 ID" + fallbackLabel(readString(candidate, "candidate_id"), "未返回"),
"参数:" + compactJSON(candidate["arguments"]),
fmt.Sprintf("执行后高认知相邻:%d 天", readInt(after, "heavy_adjacent_days")),
fmt.Sprintf("执行后最大切换:%d 次", readInt(after, "max_switch_count")),
"执行后同类型切换占比:" + formatPercent(readFloat(after, "same_type_transition_ratio")),
}
items = append(items, BuildItem(title, subtitle, tags, detailLines, candidate))
}
return items
}
func buildIssueItems(rows []any) []ItemView {
if len(rows) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(rows))
for _, raw := range rows {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
trigger := readMap(issue, "trigger")
severity := readString(issue, "severity")
dimension := readString(issue, "dimension")
title := describeIssue(issue)
detailLines := make([]string, 0, 3)
if metric := readString(trigger, "metric"); metric != "" {
detailLines = append(detailLines, fmt.Sprintf("触发指标:%s %s %.2f,实际 %.2f", metric, readString(trigger, "operator"), readFloat(trigger, "threshold"), readFloat(trigger, "actual")))
}
detailLines = append(detailLines, "问题 ID"+fallbackLabel(readString(issue, "issue_id"), "未返回"))
items = append(items, BuildItem(
title,
fmt.Sprintf("%s%s", fallbackLabel(dimension, "未标注维度"), formatSeverityCN(severity)),
[]string{formatSeverityCN(severity), fallbackLabel(dimension, "未标注维度")},
detailLines,
issue,
))
}
return items
}
func buildHealthNextStepSection(feasibility map[string]any, decision map[string]any, candidateItems []ItemView) map[string]any {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return BuildCalloutSection(
"建议后续动作",
"当前先不要继续写操作,应先与用户协商时间窗、约束或任务范围。",
"warning",
[]string{"可选方向:扩展时间窗、放宽排除约束、缩减任务量,或确认接受风险收口。"},
)
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return BuildCalloutSection(
"建议后续动作",
"优先从候选操作里选择收益明确的一项执行。",
"info",
[]string{fmt.Sprintf("当前共有 %d 个候选项;执行后建议再次调用 analyze_health 复诊。", len(candidateItems))},
)
}
return BuildCalloutSection(
"建议后续动作",
"当前可以收口;如用户仍要求微调,再按具体偏好追加读取或局部调整。",
"info",
nil,
)
}
func describeIssue(issue map[string]any) string {
issueID := readString(issue, "issue_id")
dimension := readString(issue, "dimension")
switch {
case strings.Contains(issueID, "feasibility"):
return "容量可行性不足"
case strings.Contains(issueID, "semantic_profile"):
return "任务类语义画像不完整"
case strings.Contains(issueID, "heavy_adjacent"):
return "存在高认知任务相邻"
case strings.Contains(issueID, "switch"):
return "单日任务切换偏多"
case strings.Contains(issueID, "long_block"):
return "同类任务连续块偏长"
case strings.Contains(issueID, "info"):
return "节奏整体提示"
default:
return fallbackLabel(dimension, "诊断问题")
}
}
func fallbackLabel(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(value)
}

View File

@@ -0,0 +1,326 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeRhythmView 把 analyze_rhythm 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只读取现有 metrics / issues / next_actions不改变 observation JSON
// 2. collapsed 聚焦节律结论和关键指标expanded 展开问题日、问题清单和建议动作;
// 3. detail / hard_categories 等参数只在父包参数区回显,不在这里声明它们已影响算法。
func BuildAnalyzeRhythmView(input AnalyzeRhythmViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_rhythm",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
overview := readMap(metricsMap, "overview")
days := readList(metricsMap, "days")
issues := readList(payload, "issues")
actions := readList(payload, "next_actions")
actionItems := buildRhythmActionItems(actions)
problemDayItems := buildRhythmProblemDayItems(days)
issueItems := buildIssueItems(issues)
sections := []map[string]any{
BuildKVSection("节律概览", buildRhythmOverviewFields(overview)),
}
if len(problemDayItems) > 0 {
sections = append(sections, BuildItemsSection("问题日", problemDayItems))
} else {
sections = append(sections, BuildCalloutSection("问题日", "当前没有命中的高风险问题日。", "info", nil))
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
}
if len(actionItems) > 0 {
sections = append(sections, BuildItemsSection("建议动作", actionItems))
} else {
sections = append(sections, BuildCalloutSection("建议动作", "当前节律诊断没有返回候选动作。", "info", nil))
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: buildRhythmTitle(issues),
Subtitle: buildRhythmSubtitle(issues, overview),
Metrics: buildRhythmMetrics(overview),
Items: actionItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildRhythmTitle(issues []any) string {
severity := highestSeverity(issues)
switch severity {
case "critical":
return "学习节律分析:存在高风险"
case "warning":
return "学习节律分析:有待微调"
default:
return "学习节律分析:整体平稳"
}
}
func buildRhythmSubtitle(issues []any, overview map[string]any) string {
if issue := firstHighPriorityIssue(issues); issue != nil {
return describeIssue(issue)
}
maxDay := readInt(overview, "max_switch_day")
maxSwitch := readInt(overview, "max_switch_count")
if maxDay > 0 {
return fmt.Sprintf("最大切换出现在第 %d 天,共 %d 次。", maxDay, maxSwitch)
}
return "当前学习节律没有明显异常信号。"
}
func buildRhythmMetrics(overview map[string]any) []MetricField {
return []MetricField{
BuildMetric("平均切换", fmt.Sprintf("%.1f 次/天", readFloat(overview, "avg_switches_per_day"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildMetric("块平衡", fmt.Sprintf("%d", readInt(overview, "block_balance"))),
BuildMetric("同类型占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmOverviewFields(overview map[string]any) []KVField {
return []KVField{
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(overview, "avg_switches_per_day"))),
BuildKVField("最大切换日", formatDayIndex(readInt(overview, "max_switch_day"))),
BuildKVField("最大切换次数", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildKVField("平均块长度", fmt.Sprintf("%.1f 节", readFloat(overview, "avg_block_size"))),
BuildKVField("最长同科连续", fmt.Sprintf("%d 节", readInt(overview, "longest_same_subject_run"))),
BuildKVField("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildKVField("高强度连续过长", fmt.Sprintf("%d 天", readInt(overview, "long_high_intensity_days"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(overview, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(overview, "compressed_run_count"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmProblemDayItems(days []any) []ItemView {
if len(days) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0)
for _, raw := range days {
day, ok := raw.(map[string]any)
if !ok || !isProblemRhythmDay(day) {
continue
}
dayIndex := readInt(day, "day_index")
switchCount := readInt(day, "switch_count")
fragmentation := readFloat(day, "fragmentation")
maxBlock := readInt(day, "max_block")
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
tags := []string{}
if switchCount >= 3 || fragmentation >= 0.55 {
tags = append(tags, "偏碎")
}
if heavyAdjacent {
tags = append(tags, "高认知相邻")
}
if maxBlock >= 5 {
tags = append(tags, "连续块偏长")
}
detailLines := []string{
fmt.Sprintf("切换次数:%d 次", switchCount),
"碎片化程度:" + formatFloat(fragmentation),
fmt.Sprintf("最长连续块:%d 节", maxBlock),
"科目序列:" + formatSequence(readList(day, "sequence")),
}
items = append(items, BuildItem(
formatDayIndex(dayIndex),
fmt.Sprintf("切换 %d 次,最长连续 %d 节", switchCount, maxBlock),
tags,
detailLines,
day,
))
}
return items
}
func buildRhythmActionItems(actions []any) []ItemView {
if len(actions) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(actions))
for _, raw := range actions {
action, ok := raw.(map[string]any)
if !ok {
continue
}
scope := readMap(action, "candidate_scope")
title := formatIntentCN(readString(action, "intent_code"))
if title == "" {
title = fallbackLabel(readString(action, "action_id"), "建议动作")
}
reads := formatStringList(readList(action, "required_reads"))
writes := formatStringList(readList(action, "candidate_write_tools"))
tags := []string{
fmt.Sprintf("优先级 %d", readInt(action, "priority")),
fallbackLabel(writes, "无写工具"),
}
detailLines := []string{
"需要读取:" + fallbackLabel(reads, "无"),
"候选写工具:" + fallbackLabel(writes, "无"),
"作用范围:" + formatCandidateScope(scope),
"成功标准:" + compactJSON(action["success_criteria"]),
}
items = append(items, BuildItem(
title,
fmt.Sprintf("先读 %s再考虑 %s", fallbackLabel(reads, "相关事实"), fallbackLabel(writes, "局部调整")),
tags,
detailLines,
action,
))
}
return items
}
func highestSeverity(issues []any) string {
best := "info"
bestRank := severityRank(best)
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
severity := readString(issue, "severity")
if rank := severityRank(severity); rank < bestRank {
best = severity
bestRank = rank
}
}
return strings.ToLower(strings.TrimSpace(best))
}
func firstHighPriorityIssue(issues []any) map[string]any {
var best map[string]any
bestRank := 99
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
rank := severityRank(readString(issue, "severity"))
if best == nil || rank < bestRank {
best = issue
bestRank = rank
}
}
return best
}
func isProblemRhythmDay(day map[string]any) bool {
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
return readInt(day, "switch_count") >= 3 ||
readFloat(day, "fragmentation") >= 0.55 ||
readInt(day, "max_block") >= 5 ||
heavyAdjacent
}
func formatDayIndex(day int) string {
if day <= 0 {
return "未知日期"
}
return fmt.Sprintf("第 %d 天", day)
}
func formatSequence(rows []any) string {
if len(rows) == 0 {
return "无"
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, " -> ")
}
func formatStringList(rows []any) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "、")
}
func formatCandidateScope(scope map[string]any) string {
if len(scope) == 0 {
return "未返回"
}
parts := make([]string, 0, 3)
if days := formatNumberList(readList(scope, "day_range"), "第 %d 天"); days != "" {
parts = append(parts, "日期:"+days)
}
if categories := formatStringList(readList(scope, "categories")); categories != "" {
parts = append(parts, "类别:"+categories)
}
if pool := readString(scope, "task_pool"); pool != "" {
parts = append(parts, "任务池:"+pool)
}
if len(parts) == 0 {
return compactJSON(scope)
}
return strings.Join(parts, "")
}
func formatNumberList(rows []any, pattern string) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
number := 0
switch typed := raw.(type) {
case float64:
number = int(typed)
case int:
number = typed
default:
continue
}
parts = append(parts, fmt.Sprintf(pattern, number))
}
return strings.Join(parts, "、")
}
func formatIntentCN(intent string) string {
switch strings.TrimSpace(intent) {
case "reduce_switch":
return "减少同日切换"
case "smooth_rhythm":
return "平滑高认知相邻"
case "prefer_swap":
return "优先寻找交换机会"
default:
return strings.TrimSpace(intent)
}
}

View File

@@ -0,0 +1,87 @@
package schedule_analysis
const (
// ViewTypeAnalysisResult 是第三批诊断分析结果卡片的前端识别类型。
ViewTypeAnalysisResult = "schedule.analysis_result"
// ViewVersionAnalysisResult 是当前诊断分析结果结构版本。
ViewVersionAnalysisResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// AnalysisResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议;
// 3. collapsed / expanded 保持 map 形态,方便父包直接桥接到现有展示协议。
type AnalysisResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// KVField 是展开态 kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 analysis 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区;
// 2. 不负责执行分析工具observation 必须由父包 adapter 传入;
// 3. observation 会原样写入 raw_text不能在这里改写给下游消费的 JSON。
type BuildResultViewInput struct {
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// BuildFailureViewInput 是失败视图 builder 的输入。
type BuildFailureViewInput struct {
ToolName string
Status string
Title string
Subtitle string
Observation string
ArgFields []KVField
}
// AnalyzeHealthViewInput 是 analyze_health 视图构造输入。
type AnalyzeHealthViewInput struct {
Observation string
ArgFields []KVField
}
// AnalyzeRhythmViewInput 是 analyze_rhythm 视图构造输入。
type AnalyzeRhythmViewInput struct {
Observation string
ArgFields []KVField
}