Files
smartmate/backend/newAgent/tools/schedule_analysis/rhythm.go
Losita d89e2830a9 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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
2026-04-28 20:22:22 +08:00

327 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}