Files
dingtalk-workspace-cli/internal/shortcut/minutes/workflows.go

861 lines
43 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var minutesWorkflowArtifacts = []string{"basic", "summary", "keywords", "transcript", "todos"}
var (
minutesMkdirTemp = os.MkdirTemp
minutesRemoveAll = os.RemoveAll
minutesWriteFile = os.WriteFile
minutesStat = os.Stat
minutesRename = os.Rename
minutesGetwd = os.Getwd
minutesEvalSymlinks = filepath.EvalSymlinks
minutesRel = filepath.Rel
minutesLstat = os.Lstat
minutesMkdir = os.Mkdir
minutesMarshalIndent = json.MarshalIndent
)
var RecordWrapUp = shortcut.Shortcut{
Service: "minutes", Command: "+record-wrap-up", Product: "minutes",
Description: "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
Intent: "会议结束时一次完成 stop 与 basic/summary/keywords/transcript/todos 读取;停止已生效但产物未就绪时返回非零 partial 结果和 taskUuid,不会诱导重复 stop。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+record-wrap-up", "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
"已知正在录制的 taskUuid,会议结束后要停止录制并立即收口转写、纪要、关键词和行动项时使用",
[]string{"仍要继续录音时使用 +record-pause;只停止、不等待产物时使用 +record-stop"},
[]string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "正在录制的听记 taskUuid", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "停止后等待的产物", Enum: minutesWorkflowArtifacts},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待产物秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"wait-timeout", "poll-interval", "page-limit"}, Description: "超时、轮询间隔和逐字稿页数上限必须大于 0"}},
Tips: []string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`},
Validate: validateMinutesWorkflowWait,
Execute: executeMinutesRecordWrapUp,
}
var UploadAndAnalyze = shortcut.Shortcut{
Service: "minutes", Command: "+upload-and-analyze", Product: "minutes",
Description: "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
Intent: "从本地媒体一次完成 upload create/PUT/complete/read-back,再等待选定听记产物;后续分析失败仍返回已创建 taskUuid 和恢复动作。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+upload-and-analyze", "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
"有本地音视频且希望一次创建听记、等待基础分析,并可继续生成思维导图或发言人洞察时使用",
[]string{"只需上传时使用 +upload;已有 taskUuid 时分别使用 +detail/+mindmap/+speaker-insights"},
[]string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件;与 --resume-id 二选一"},
{Name: "resume-id", Type: shortcut.FlagString, Desc: "先前已成功上传的 taskUuid;只恢复分析、不重复上传"},
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "上传 complete 超时秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待分析产物秒数"},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "等待的分析产物", Enum: minutesWorkflowArtifacts},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
{Name: "mindmap", Type: shortcut.FlagBool, Desc: "继续创建并等待思维导图"},
{Name: "speaker-insights", Type: shortcut.FlagBool, Desc: "继续创建并等待发言人总结"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"file", "resume-id"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "wait-timeout", "poll-interval", "page-limit"}, Description: "上传超时、分析超时、轮询间隔和逐字稿页数上限必须大于 0"},
},
Tips: []string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("complete-timeout") <= 0 {
return apperrors.NewValidation("--complete-timeout 必须大于 0")
}
return validateMinutesWorkflowWait(rt)
},
Execute: executeMinutesUploadAndAnalyze,
}
var Mindmap = shortcut.Shortcut{
Service: "minutes", Command: "+mindmap", Product: "minutes",
Description: "创建听记思维导图并轮询到明确成功、失败或超时",
Intent: "已有 taskUuid 且需要思维导图时使用;创建只执行一次,随后只轮询 taskStatus,失败/超时均返回非零和可继续查询的 taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+mindmap", "创建听记思维导图并轮询到明确成功、失败或超时",
"听记内容已就绪,需要触发并等待平台思维导图产物时使用",
[]string{"只查询已经触发的任务状态时使用原子 mind-graph status;短音频或无有效内容可能明确失败"},
[]string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`},
Validate: validateMinutesPolling,
Execute: executeMinutesMindmap,
}
var SpeakerInsights = shortcut.Shortcut{
Service: "minutes", Command: "+speaker-insights", Product: "minutes",
Description: "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
Intent: "需要按发言人汇总听记内容时使用;严格要求 create 返回 taskId,读取未就绪时有界重试,失败或超时返回 taskId/taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+speaker-insights", "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
"逐字稿已有多位发言人,需要触发并读取平台发言人段落总结时使用",
[]string{"只改发言人昵称时使用 +speaker-replace;无有效发言内容时平台可能不生成结果"},
[]string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "先前 create 返回的异步 taskId,恢复时用于输出追踪"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`},
Validate: validateMinutesPolling,
Execute: executeMinutesSpeakerInsights,
}
var PrepareASR = shortcut.Shortcut{
Service: "minutes", Command: "+prepare-asr", Product: "minutes",
Description: "读取个人热词、计算差异、按需新增并可显式同步删除后读回验证",
Intent: "录音/上传前准备 ASR 专有词表;默认只增加缺失热词,只有 --sync 才删除未出现在目标集合中的现有热词。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+prepare-asr", "按需新增个人热词并可显式同步删除后读回验证;dry-run 不访问远端",
"录音或上传前要补充人名、项目名等 ASR 热词,并希望看到差异和最终读回验证时使用",
[]string{"只查看现有热词时使用原子 hot-word list;不明确接受删除时不要使用 --sync"},
[]string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "words", Type: shortcut.FlagStringSlice, Desc: "目标热词,逗号分隔", Required: true},
{Name: "sync", Type: shortcut.FlagBool, Desc: "删除目标集合之外的现有热词"},
},
Tips: []string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`},
Execute: executeMinutesPrepareASR,
}
var ExportPack = shortcut.Shortcut{
Service: "minutes", Command: "+export-pack", Product: "minutes",
Description: "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
Intent: "需要离线归档 basic/summary/keywords/transcript/todos,可选媒体文件时使用;全部必需产物验证通过后才原子发布目录。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+export-pack", "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
"已知 taskUuid,需要把多个已验证产物和完整性 manifest 安全归档到工作目录时使用",
[]string{"只下载媒体时使用 +download;目标目录已存在时本命令拒绝覆盖"},
[]string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`}),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内的新归档目录", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要导出的产物", Enum: minutesWorkflowArtifacts},
{Name: "include-media", Type: shortcut.FlagBool, Desc: "同时下载音视频"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "输出必须是安全的工作目录相对路径"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return localio.ValidateOutput(rt.Str("output"))
},
Execute: executeMinutesExportPack,
}
var Share = shortcut.Shortcut{
Service: "minutes", Command: "+share", Product: "minutes",
Description: "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者已确认成员钉钉 UID 和权限,需批量授权时使用;逐成员调用以区分成功/失败,默认首错停止。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+share", "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要授予 view/download/edit 权限并审计每个成员结果时使用",
[]string{"当前用户自己申请权限时使用 +apply-permission;只有姓名而无稳定 UID 时先用 contact 命令消歧"},
[]string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(true),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`},
Validate: validateMinutesShare,
Execute: executeMinutesShare,
}
var Unshare = shortcut.Shortcut{
Service: "minutes", Command: "+unshare", Product: "minutes",
Description: "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者明确要撤销稳定成员 UID 的听记访问时使用;默认首错停止,任何部分失败都返回非零。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+unshare", "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要撤销其对一个或多个听记的访问权限时使用",
[]string{"要授权时使用 +share;成员或听记 ID 未确认时不要撤销"},
[]string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(false),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`},
Validate: validateMinutesShare,
Execute: executeMinutesUnshare,
}
func validateMinutesWorkflowWait(rt *shortcut.RuntimeContext) error {
if rt.Int("wait-timeout") <= 0 || rt.Int("poll-interval") <= 0 || rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("等待超时、轮询间隔和逐字稿页数上限必须大于 0")
}
return nil
}
func validateMinutesPolling(rt *shortcut.RuntimeContext) error {
if rt.Int("timeout") <= 0 || rt.Int("interval") <= 0 {
return apperrors.NewValidation("--timeout 和 --interval 必须大于 0")
}
return nil
}
func selectedWorkflowArtifacts(rt *shortcut.RuntimeContext) []string {
selected := rt.StrSlice("artifacts")
if len(selected) == 0 {
return append([]string(nil), minutesWorkflowArtifacts...)
}
return uniqueStrings(selected)
}
func executeMinutesRecordWrapUp(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.record_wrap_up", map[string]any{"taskUuid": id, "stages": []string{"record.stop", "artifacts.wait"}, "artifacts": artifacts}))
}
stopData, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, map[string]any{"cmd": "end", "uuid": id})
if err != nil {
return err
}
if _, err := minutesdata.RecordResult("end", id, stopData); err != nil {
return err
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
payload := map[string]any{
"operation": "minutes.record_wrap_up", "complete": len(failures) == 0, "taskUuid": id,
"stages": []map[string]any{{"name": "record.stop", "complete": true}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}},
"artifacts": bundle, "failures": failures,
}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "dws minutes +detail --id <taskUuid>"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_record_wrap_up_incomplete", "artifacts")
}
func executeMinutesUploadAndAnalyze(rt *shortcut.RuntimeContext) error {
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
upload := map[string]any{"operation": "minutes.upload", "resume": rt.Str("resume-id") != "", "taskUuid": rt.Str("resume-id"), "executed": false}
if rt.Str("resume-id") == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
}
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload_and_analyze", map[string]any{"upload": upload, "artifacts": artifacts, "mindmap": rt.Bool("mindmap"), "speakerInsights": rt.Bool("speaker-insights")}))
}
id := strings.TrimSpace(rt.Str("resume-id"))
upload := map[string]any{"operation": "minutes.upload", "complete": true, "resumed": true, "taskUuid": id, "verified": true}
if id == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
id, _ = upload["taskUuid"].(string)
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
stages := []map[string]any{{"name": "upload", "complete": true, "result": upload}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}}
if rt.Bool("mindmap") {
result, runErr := runMinutesMindmap(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true)
stage := map[string]any{"name": "mindmap", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "mindmap", "error": runErr.Error()})
}
stages = append(stages, stage)
}
if rt.Bool("speaker-insights") {
result, runErr := runMinutesSpeakerInsights(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true, "")
stage := map[string]any{"name": "speaker-insights", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "speaker-insights", "error": runErr.Error()})
}
stages = append(stages, stage)
}
payload := map[string]any{"operation": "minutes.upload_and_analyze", "complete": len(failures) == 0, "taskUuid": id, "stages": stages, "artifacts": bundle, "failures": failures}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "resume analysis with +detail/+mindmap/+speaker-insights; do not upload again"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_upload_analysis_incomplete", "analysis")
}
func executeMinutesMindmap(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.mindmap", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesMindmap(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"))
if err := rt.Output(payload); err != nil {
return err
}
return err
}
func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool) (map[string]any, error) {
createAcknowledged := false
if create {
// create_mind_graph is observed to return either an explicit success
// envelope or an empty acknowledgement. Empty alone is never success;
// continue only so the status read-back can prove the final effect.
created, err := rt.CallMCPWriteData("minutes", "create_mind_graph", map[string]any{"taskUuid": id})
if err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "create"}, err
}
createAcknowledged = minutesdata.RequireWriteAcknowledgement("mind graph create", created) == nil
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "query_mind_graph_status", map[string]any{"taskUuid": id})
if callErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}, callErr
}
status, result, parseErr := minutesdata.MindGraphStatus(data)
if parseErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts}, parseErr
}
if status == 1 {
return map[string]any{"operation": "minutes.mindmap", "complete": true, "taskUuid": id, "taskStatus": status, "attempts": attempts, "createAttempted": create, "createAcknowledged": createAcknowledged, "verified": true, "result": result}, nil
}
if status == 2 {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
}
if minutesPollDeadlineReached(deadline, interval) {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "attempts": attempts}, err
}
}
}
func executeMinutesSpeakerInsights(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_insights", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesSpeakerInsights(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"), rt.Str("task-id"))
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool, taskID string) (map[string]any, error) {
status := "resume"
if create {
created, err := rt.CallMCPWriteDataStrict("minutes", "create_speaker_summary", map[string]any{"uuids": []string{id}})
if err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, err
}
var parseErr error
taskID, status, parseErr = minutesdata.SpeakerSummaryTask(created)
if parseErr != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, parseErr
}
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "get_speaker_summary", map[string]any{"uuids": []string{id}})
if callErr == nil {
result, resultErr := minutesdata.SpeakerSummaryResult(data)
if resultErr == nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": true, "taskUuid": id, "taskId": taskID, "createStatus": status, "attempts": attempts, "result": result}, nil
}
callErr = resultErr
}
if !speakerSummaryPending(callErr) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, callErr
}
if minutesPollDeadlineReached(deadline, interval) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts}, err
}
}
}
func speakerSummaryPending(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "query empty") || strings.Contains(message, "processing") || strings.Contains(message, "not ready") || strings.Contains(message, "result is empty") || strings.Contains(message, "business error: code 000") || strings.Contains(message, "暂无")
}
func executeMinutesPrepareASR(rt *shortcut.RuntimeContext) error {
desired := uniqueStrings(rt.StrSlice("words"))
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.prepare_asr", map[string]any{
"desired": desired, "sync": rt.Bool("sync"), "remotePreconditions": []string{"read current hot words", "compute additions and deletions", "apply writes", "read back exact set"},
}))
}
currentData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
return err
}
current, err := minutesdata.HotWords(currentData)
if err != nil {
return err
}
toAdd := stringDifference(desired, current)
toDelete := []string{}
if rt.Bool("sync") {
toDelete = stringDifference(current, desired)
}
plan := map[string]any{"operation": "minutes.prepare_asr", "before": current, "desired": desired, "add": toAdd, "delete": toDelete, "sync": rt.Bool("sync")}
stages := []map[string]any{}
if len(toAdd) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "add_personal_hot_word", map[string]any{"hotWordList": toAdd})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word add", data)
}
stages = append(stages, map[string]any{"name": "add", "complete": writeErr == nil, "words": toAdd})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "review current hot words before retrying"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_add_failed", "add")
}
}
if len(toDelete) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "delete_personal_hotword", map[string]any{"hotWordList": toDelete})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word delete", data)
}
stages = append(stages, map[string]any{"name": "delete", "complete": writeErr == nil, "words": toDelete})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "list hot words; additions may already be applied"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_delete_failed", "delete")
}
}
verifiedData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
plan["complete"], plan["stages"] = false, stages
if outputErr := rt.Output(plan); outputErr != nil {
return outputErr
}
return err
}
verified, err := minutesdata.HotWords(verifiedData)
if err != nil {
return err
}
missing := stringDifference(desired, verified)
unexpected := []string{}
if rt.Bool("sync") {
unexpected = stringDifference(verified, desired)
}
plan["complete"], plan["verified"], plan["after"], plan["stages"] = len(missing) == 0 && len(unexpected) == 0, len(missing) == 0 && len(unexpected) == 0, verified, stages
if len(missing) > 0 || len(unexpected) > 0 {
plan["missing"], plan["unexpected"] = missing, unexpected
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_verification_failed", "verify")
}
return rt.Output(plan)
}
func executeMinutesExportPack(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, rt.Int("page-limit"))
if len(failures) > 0 {
payload := map[string]any{"operation": "minutes.export_pack", "complete": false, "taskUuid": id, "failures": failures, "published": false}
return outputWorkflowResult(rt, payload, true, "minutes_export_pack_incomplete", "collect")
}
target, relative, parent, err := prepareExportTarget(rt.Str("output"))
if err != nil {
return err
}
tempDir, err := minutesMkdirTemp(parent, ".dws-minutes-export-*")
if err != nil {
return err
}
cleanup := true
defer func() {
if cleanup {
_ = minutesRemoveAll(tempDir)
}
}()
files := map[string]map[string]any{}
for _, name := range artifacts {
filename := name + ".json"
value := bundle[name]
if name == "summary" {
filename = "summary.md"
if err := minutesWriteFile(filepath.Join(tempDir, filename), []byte(value.(string)), 0o600); err != nil {
return err
}
} else if err := writeJSONFile(filepath.Join(tempDir, filename), value); err != nil {
return err
}
info, err := minutesStat(filepath.Join(tempDir, filename))
if err != nil {
return err
}
files[name] = map[string]any{"file": filename, "sizeBytes": info.Size(), "complete": true}
}
if rt.Bool("include-media") {
mediaData, callErr := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
if callErr != nil {
return callErr
}
mediaURL, parseErr := minutesdata.MediaURL(mediaData)
if parseErr != nil {
return parseErr
}
download, downloadErr := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{BaseDir: tempDir, Output: "media/", PreferredName: id + mediaExtension(mediaURL)})
if downloadErr != nil {
return downloadErr
}
files["media"] = map[string]any{"file": download.RelativePath, "sizeBytes": download.SizeBytes, "complete": true}
}
manifest := map[string]any{"version": 1, "operation": "minutes.export_pack", "taskUuid": id, "complete": true, "generatedAt": time.Now().UTC().Format(time.RFC3339), "files": files}
if err := writeJSONFile(filepath.Join(tempDir, "manifest.json"), manifest); err != nil {
return err
}
if err := minutesRename(tempDir, target); err != nil {
return fmt.Errorf("发布听记归档目录失败: %w", err)
}
cleanup = false
return rt.Output(map[string]any{"operation": "minutes.export_pack", "complete": true, "taskUuid": id, "published": true, "path": filepath.ToSlash(relative), "manifest": filepath.ToSlash(filepath.Join(relative, "manifest.json")), "files": files})
}
func minutesShareFlags(includePermission bool) []shortcut.Flag {
flags := []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
{Name: "member-uids", Type: shortcut.FlagStringSlice, Desc: "稳定成员钉钉 UID,最多 50 个", Required: true},
}
if includePermission {
flags = append(flags,
shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Desc: "授予权限", Required: true, Enum: []string{"view", "download", "edit"}},
shortcut.Flag{Name: "cover", Type: shortcut.FlagBool, Desc: "覆盖已有权限"},
shortcut.Flag{Name: "sub-resources", Type: shortcut.FlagStringSlice, Desc: "可选子资源", Enum: []string{"OrigContent", "Summary", "Analysis", "Note"}},
)
}
return append(flags, shortcut.Flag{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "成员失败策略", Enum: []string{"stop", "continue"}})
}
func validateMinutesShare(rt *shortcut.RuntimeContext) error {
ids, members := minutesIDs(rt), uniqueStrings(rt.StrSlice("member-uids"))
if len(ids) == 0 || len(ids) > 50 || len(members) == 0 || len(members) > 50 {
return apperrors.NewValidation("听记和成员 UID 数量必须各为 1..50")
}
return nil
}
func executeMinutesShare(rt *shortcut.RuntimeContext) error {
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
return executeMinutesPermissionLedger(rt, "share", "add_member_permission", func(member string) map[string]any {
params := map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}, "policyId": policy}
if rt.Changed("cover") {
params["coverPermission"] = fmt.Sprintf("%t", rt.Bool("cover"))
}
if values := rt.StrSlice("sub-resources"); len(values) > 0 {
params["roleSubResourceIds"] = values
}
return params
})
}
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
if !rt.DryRun() {
for _, id := range minutesIDs(rt) {
data, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
}
if _, err := minutesdata.Basic(id, data); err != nil {
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
}
}
}
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
})
}
func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool string, params func(string) map[string]any) error {
members := uniqueStrings(rt.StrSlice("member-uids"))
plan := map[string]any{"operation": "minutes." + operation, "taskUuids": minutesIDs(rt), "memberCount": len(members), "members": members}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes."+operation, plan))
}
results := []map[string]any{}
failures := []map[string]any{}
unattempted := []string{}
for index, member := range members {
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
if err == nil {
if operation == "unshare" {
err = minutesdata.RequirePermissionMutationAcknowledgement(operation, minutesIDs(rt), []string{member}, data)
} else {
err = minutesdata.RequireWriteAcknowledgement(operation, data)
}
}
if err != nil {
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
if rt.Str("failure-policy") == "stop" {
unattempted = append(unattempted, members[index+1:]...)
break
}
continue
}
results = append(results, map[string]any{"memberUid": member, "complete": true})
}
plan["complete"], plan["succeeded"], plan["failed"], plan["unattempted"], plan["results"], plan["failures"] = len(failures) == 0, len(results), len(failures), unattempted, results, failures
return outputWorkflowResult(rt, plan, len(failures) > 0, "minutes_permission_partial", operation)
}
func collectMinutesArtifactsOnce(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int) (map[string]any, []map[string]any) {
bundle := map[string]any{}
failures := []map[string]any{}
for _, artifact := range artifacts {
var value any
var err error
switch artifact {
case "basic":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.Basic(id, data)
}
case "summary":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.SummaryText(data)
if err == nil && strings.TrimSpace(value.(string)) == "" {
err = fmt.Errorf("minutes summary is explicitly empty; analysis readiness is not proven")
}
}
case "keywords":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_keywords", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("keywords", id, data)
value = data["result"]
}
case "transcript":
var transcript minutesdata.TranscriptResult
transcript, err = collectTranscriptForMinutes(rt, id, pageLimit)
value = minutesdata.TranscriptPayload(id, "0", transcript)
if err == nil && len(transcript.Paragraphs) == 0 {
err = fmt.Errorf("minutes transcript is explicitly empty; analysis readiness is not proven")
}
case "todos":
var data map[string]any
data, err = rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("todos", id, data)
value = data["result"]
}
default:
err = fmt.Errorf("unsupported artifact %q", artifact)
}
if err != nil {
failures = append(failures, map[string]any{"artifact": artifact, "error": err.Error()})
continue
}
bundle[artifact] = value
}
return bundle, failures
}
func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int, timeout, interval time.Duration) (map[string]any, []map[string]any, int) {
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
if len(failures) == 0 || minutesPollDeadlineReached(deadline, interval) {
return bundle, failures, attempts
}
if err := waitMinutesInterval(rt, interval); err != nil {
return bundle, append(failures, map[string]any{"artifact": "wait", "error": err.Error()}), attempts
}
}
}
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
commandContext := rt.Command().Context()
if commandContext == nil {
commandContext = context.Background()
}
select {
case <-commandContext.Done():
return commandContext.Err()
case <-timer.C:
return nil
}
}
func minutesPollDeadlineReached(deadline time.Time, interval time.Duration) bool {
return !time.Now().Add(interval).Before(deadline)
}
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
if err := rt.Output(payload); err != nil {
return err
}
if failed {
return minutesCompositeError(reason, stage, payload)
}
return nil
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func stringDifference(left, right []string) []string {
other := map[string]bool{}
for _, value := range right {
other[value] = true
}
out := []string{}
for _, value := range left {
if !other[value] {
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func prepareExportTarget(output string) (target, relative, parent string, err error) {
if err := localio.ValidateOutput(output); err != nil {
return "", "", "", err
}
cwd, err := minutesGetwd()
if err != nil {
return "", "", "", err
}
realBase, err := minutesEvalSymlinks(cwd)
if err != nil {
return "", "", "", err
}
relative = filepath.Clean(output)
target = filepath.Join(realBase, relative)
parent = filepath.Dir(target)
rel, err := minutesRel(realBase, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", "", "", fmt.Errorf("LOCAL_PATH_UNSAFE: 输出目录逃逸工作目录")
}
if err := ensureExportParent(realBase, filepath.Dir(rel)); err != nil {
return "", "", "", err
}
if _, err := minutesLstat(target); err == nil {
return "", "", "", fmt.Errorf("LOCAL_FILE_EXISTS: 目标目录已存在;请选择新的输出路径")
} else if !os.IsNotExist(err) {
return "", "", "", err
}
return target, relative, parent, nil
}
func ensureExportParent(base, relative string) error {
current := base
if relative == "." || relative == "" {
return nil
}
for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := minutesLstat(current)
if os.IsNotExist(err) {
if err := minutesMkdir(current, 0o700); err != nil {
return err
}
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: 输出父路径不是安全目录")
}
}
return nil
}
func writeJSONFile(path string, value any) error {
raw, err := minutesMarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return minutesWriteFile(path, raw, 0o600)
}
func init() {
shortcut.Register(finalizeMinutesShortcuts(RecordWrapUp, UploadAndAnalyze, Mindmap, SpeakerInsights, PrepareASR, ExportPack, Share, Unshare)...)
}