405 lines
16 KiB
Go
405 lines
16 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package smart
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
|
|
|
"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"
|
|
)
|
|
|
|
// ReplaceBatch: apply MANY word replacements to one minute (听记) in a single
|
|
// command.
|
|
//
|
|
// dws's atomic tool replace_minutes_text handles exactly one
|
|
// originalText→replacedText pair (see internal/helpers/minutes.go, the
|
|
// +word-replace 1:1 shortcut). Fixing several terms at once means calling it
|
|
// repeatedly and eyeballing each result. This shortcut takes multiple --pair
|
|
// "原文=>替换" entries, validates them (rejecting duplicate source words so two
|
|
// rules don't fight over the same term), applies each via replace_minutes_text
|
|
// and aggregates a per-pair {applied|error} report through rt.Output. The
|
|
// default policy stops at the first error; explicit continue still returns
|
|
// non-zero when any write fails.
|
|
//
|
|
// dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰" --pair "Q2=>第二季度"
|
|
var ReplaceBatch = shortcut.Shortcut{
|
|
Service: "minutes",
|
|
Command: "+replace-batch",
|
|
Product: "minutes",
|
|
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
|
|
Intent: "当你要在同一条听记里一次纠正多个错识别人名或术语时使用;支持重复 --pair,或通过 --json 接受字面量、@相对文件和 stdin。" +
|
|
"命令会先读取完整逐字稿并校验所有原文、空值和重复规则,再逐项写入并重新读取完整逐字稿验证效果。" +
|
|
"默认首错停止;只有显式 failure-policy=continue 才继续,但任一失败仍返回非零及 applied/failed/unattempted ledger。底层不是事务且没有自动回滚,并会同时影响逐字稿和摘要。",
|
|
Risk: shortcut.RiskWrite,
|
|
Safety: contract.SafetySpec{
|
|
Effect: "write", Risk: "medium",
|
|
Confirmation: "user_required", Idempotency: "unknown",
|
|
},
|
|
Contract: corecmd.ContractDecl{
|
|
Identity: contract.ToolIdentitySpec{
|
|
ProductID: "minutes",
|
|
Name: "shortcut_replace_batch",
|
|
CanonicalPath: "minutes.shortcut_replace_batch",
|
|
CLIPath: "minutes +replace-batch",
|
|
PrimaryCLIPath: "minutes +replace-batch",
|
|
},
|
|
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
|
|
Interface: &contract.InterfaceSpec{
|
|
Mode: "composite",
|
|
Availability: "available",
|
|
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
|
},
|
|
Selection: contract.SelectionSpec{
|
|
AgentSummary: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
|
|
UseWhen: []string{"当你要在同一条听记里一次性纠正多个词(如把多个错识别的人名/术语统一替换),而底层工具一次只能替换一组时使用;内部按多个 --pair \"原文=>替换\" 逐组调用替换工具,先在本地校验去重(同一个「原文」不能出现两次,避免两条规则互相打架),再逐组应用并聚合每组的成功/失败结果,某一组失败不会中断其余组。这是写操作,会实际修改听记文字内容,请确认 taskUuid 与替换规则无误。"},
|
|
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
|
Examples: []string{
|
|
"dws minutes +replace-batch --id <taskUuid> --pair \"张三=>张三丰\"",
|
|
"dws minutes +replace-batch --id <taskUuid> --pair \"Q2=>第二季度\" --pair \"PM=>产品经理\"",
|
|
},
|
|
},
|
|
DryRun: &contract.DryRunSpec{
|
|
PreviewKind: contract.DryRunPreviewPlan,
|
|
RemoteReads: false,
|
|
},
|
|
Parameters: []contract.ParamDecl{
|
|
{Name: "pair", Description: `替换规则,格式 "原文=>替换",可重复传多组(必填);每组原文不能为空且不能重复`},
|
|
{Name: "json", Description: "JSON 字面量、@工作目录相对文件或 - 表示 stdin"},
|
|
{Name: "failure-policy", Description: "stop=首错停止;continue=显式继续,但只要有失败仍返回非零"},
|
|
},
|
|
},
|
|
Flags: []shortcut.Flag{
|
|
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
|
|
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组`},
|
|
{Name: "json", Type: shortcut.FlagString, Desc: "替换规则 JSON 字面量、@相对文件或 - 表示 stdin"},
|
|
{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "失败策略", Enum: []string{"stop", "continue"}},
|
|
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿写前/写后验证的翻页上限"},
|
|
},
|
|
Constraints: []shortcut.Constraint{
|
|
{
|
|
Kind: shortcut.ConstraintCustom,
|
|
Flags: []string{"pair", "json"},
|
|
Description: `--pair 与 --json 至少提供一种;规则必须使用 "原文=>替换" 或 JSON 数组;原文不能为空且不能重复`,
|
|
},
|
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
|
},
|
|
Tips: []string{
|
|
`dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰"`,
|
|
`dws minutes +replace-batch --id <taskUuid> --pair "Q2=>第二季度" --pair "PM=>产品经理"`,
|
|
},
|
|
Validate: func(rt *shortcut.RuntimeContext) error {
|
|
if rt.Int("page-limit") <= 0 {
|
|
return apperrors.NewValidation("--page-limit 必须大于 0")
|
|
}
|
|
if len(rt.StrSlice("pair")) > 0 {
|
|
_, err := parseReplacePairs(rt.StrSlice("pair"))
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
taskUUID := rt.Str("id")
|
|
pairs, err := loadReplacePairs(rt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rt.DryRun() {
|
|
replacements := make([]map[string]any, 0, len(pairs))
|
|
for _, p := range pairs {
|
|
replacements = append(replacements, map[string]any{
|
|
"originalText": p.orig,
|
|
"replacedText": p.repl,
|
|
})
|
|
}
|
|
return rt.Output(map[string]any{
|
|
"operation": "minutes.replace_batch",
|
|
"dry_run": true,
|
|
"dryRun": true,
|
|
"preview_kind": contract.DryRunPreviewPlan,
|
|
"executed": false,
|
|
"failurePolicy": rt.Str("failure-policy"),
|
|
"taskUuid": taskUUID,
|
|
"total": len(pairs),
|
|
"replacements": replacements,
|
|
})
|
|
}
|
|
|
|
currentText, err := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, pair := range pairs {
|
|
if strings.Count(currentText, pair.orig) == 0 {
|
|
return apperrors.NewValidation(fmt.Sprintf("完整逐字稿中不存在待替换原文 %q;未执行任何写入", pair.orig))
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(pairs))
|
|
applied := 0
|
|
failed := 0
|
|
unattempted := 0
|
|
for index, p := range pairs {
|
|
// Params mirror helpers replace_minutes_text call site: taskUuid /
|
|
// originalText / replacedText.
|
|
writeData, callErr := rt.CallMCPWriteDataStrict("minutes", "replace_minutes_text", map[string]any{
|
|
"taskUuid": taskUUID,
|
|
"originalText": p.orig,
|
|
"replacedText": p.repl,
|
|
})
|
|
if callErr == nil {
|
|
callErr = minutesdata.RequireWriteAcknowledgement("replace text", writeData)
|
|
}
|
|
entry := map[string]any{"originalText": p.orig, "replacedText": p.repl}
|
|
acknowledged := callErr == nil
|
|
if acknowledged {
|
|
entry["acknowledged"] = true
|
|
beforeSource := strings.Count(currentText, p.orig)
|
|
beforeTarget := strings.Count(currentText, p.repl)
|
|
afterText, readErr := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
|
|
if readErr != nil {
|
|
callErr = fmt.Errorf("写入已确认但逐字稿读回失败: %w", readErr)
|
|
} else if !replaceReadbackVerified(currentText, afterText, p, beforeSource, beforeTarget) {
|
|
callErr = fmt.Errorf("写入已确认但逐字稿读回未证明替换生效")
|
|
} else {
|
|
currentText = afterText
|
|
entry["verified"] = true
|
|
}
|
|
}
|
|
if callErr != nil {
|
|
entry["error"] = callErr.Error()
|
|
entry["remoteEffectUnknown"] = acknowledged
|
|
failed++
|
|
results = append(results, entry)
|
|
if rt.Str("failure-policy") == "stop" {
|
|
for _, pending := range pairs[index+1:] {
|
|
results = append(results, map[string]any{
|
|
"originalText": pending.orig,
|
|
"replacedText": pending.repl,
|
|
"unattempted": true,
|
|
})
|
|
unattempted++
|
|
}
|
|
break
|
|
}
|
|
} else {
|
|
entry["applied"] = true
|
|
applied++
|
|
results = append(results, entry)
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"ok": failed == 0,
|
|
"partial": applied > 0 && (failed > 0 || unattempted > 0),
|
|
"taskUuid": taskUUID,
|
|
"failurePolicy": rt.Str("failure-policy"),
|
|
"total": len(pairs),
|
|
"applied": applied,
|
|
"failed": failed,
|
|
"unattempted": unattempted,
|
|
"results": results,
|
|
}
|
|
if err := rt.Output(payload); err != nil {
|
|
return err
|
|
}
|
|
if failed > 0 {
|
|
return apperrors.NewAPI(
|
|
fmt.Sprintf("批量替换未完成:成功 %d、失败 %d、未执行 %d", applied, failed, unattempted),
|
|
apperrors.WithOperation("minutes/replace_minutes_text"),
|
|
apperrors.WithOrigin("shortcut"),
|
|
apperrors.WithFailureStage("write"),
|
|
apperrors.WithReason("minutes_replace_partial_failure"),
|
|
apperrors.WithExecutionStarted(true),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithDetails(payload),
|
|
)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// replacePair is one parsed "原文=>替换" rule.
|
|
type replacePair struct {
|
|
orig string
|
|
repl string
|
|
}
|
|
|
|
// replacePairSep separates original from replacement text in a --pair value.
|
|
// "=>" is chosen because a bare "=" commonly appears inside the source text.
|
|
const replacePairSep = "=>"
|
|
|
|
// parseReplacePairs parses "原文=>替换" entries and rejects malformed input,
|
|
// empty source text, and duplicate source words (which would make two rules
|
|
// contend for the same term).
|
|
func parseReplacePairs(raw []string) ([]replacePair, error) {
|
|
pairs := make([]replacePair, 0, len(raw))
|
|
seen := make(map[string]struct{}, len(raw))
|
|
for _, r := range raw {
|
|
idx := strings.Index(r, replacePairSep)
|
|
if idx < 0 {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("替换规则 %q 缺少分隔符 %q,应为 \"原文%s替换\"", r, replacePairSep, replacePairSep))
|
|
}
|
|
orig := strings.TrimSpace(r[:idx])
|
|
repl := strings.TrimSpace(r[idx+len(replacePairSep):])
|
|
if orig == "" {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("替换规则 %q 的「原文」不能为空", r))
|
|
}
|
|
if _, dup := seen[orig]; dup {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", orig))
|
|
}
|
|
seen[orig] = struct{}{}
|
|
pairs = append(pairs, replacePair{orig: orig, repl: repl})
|
|
}
|
|
return pairs, nil
|
|
}
|
|
|
|
func loadReplacePairs(rt *shortcut.RuntimeContext) ([]replacePair, error) {
|
|
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rt.Changed("json") {
|
|
text, err := localio.ReadTextInput(rt.Str("json"), rt.Command().InOrStdin(), 8<<20)
|
|
if err != nil {
|
|
return nil, apperrors.NewValidation(err.Error())
|
|
}
|
|
jsonPairs, err := parseReplaceJSON(text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pairs = append(pairs, jsonPairs...)
|
|
}
|
|
return validateReplacePairs(pairs)
|
|
}
|
|
|
|
func parseReplaceJSON(raw string) ([]replacePair, error) {
|
|
type wirePair struct {
|
|
Source string `json:"source"`
|
|
Replacement string `json:"replacement"`
|
|
OriginalText string `json:"originalText"`
|
|
ReplacedText string `json:"replacedText"`
|
|
}
|
|
var direct []wirePair
|
|
if err := json.Unmarshal([]byte(raw), &direct); err != nil {
|
|
var envelope struct {
|
|
Replacements []wirePair `json:"replacements"`
|
|
}
|
|
if envelopeErr := json.Unmarshal([]byte(raw), &envelope); envelopeErr != nil || envelope.Replacements == nil {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("--json 必须是替换规则数组或 {\"replacements\":[...]}: %v", err))
|
|
}
|
|
direct = envelope.Replacements
|
|
}
|
|
if len(direct) == 0 {
|
|
return nil, apperrors.NewValidation("--json 至少需要一条替换规则")
|
|
}
|
|
pairs := make([]replacePair, 0, len(direct))
|
|
for index, item := range direct {
|
|
source := item.Source
|
|
if source == "" {
|
|
source = item.OriginalText
|
|
}
|
|
replacement := item.Replacement
|
|
if replacement == "" {
|
|
replacement = item.ReplacedText
|
|
}
|
|
if strings.TrimSpace(source) == "" {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("--json 第 %d 条规则缺少 source/originalText", index))
|
|
}
|
|
pairs = append(pairs, replacePair{orig: strings.TrimSpace(source), repl: strings.TrimSpace(replacement)})
|
|
}
|
|
return pairs, nil
|
|
}
|
|
|
|
func validateReplacePairs(pairs []replacePair) ([]replacePair, error) {
|
|
if len(pairs) == 0 {
|
|
return nil, apperrors.NewValidation("至少需要一条替换规则")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, pair := range pairs {
|
|
if strings.TrimSpace(pair.orig) == "" {
|
|
return nil, apperrors.NewValidation("替换规则的原文不能为空")
|
|
}
|
|
if seen[pair.orig] {
|
|
return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", pair.orig))
|
|
}
|
|
seen[pair.orig] = true
|
|
}
|
|
return pairs, nil
|
|
}
|
|
|
|
func minutesReplaceTranscriptText(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (string, error) {
|
|
result, err := collectMinutesTranscript(rt, taskUUID, "0", "", false, pageLimit)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(result.Paragraphs) == 0 {
|
|
return "", fmt.Errorf("逐字稿为空,无法证明文字替换目标或写入结果")
|
|
}
|
|
parts := make([]string, 0, len(result.Paragraphs))
|
|
var collect func(any)
|
|
collect = func(value any) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range typed {
|
|
switch key {
|
|
case "paragraph", "sentence", "word", "text", "content":
|
|
if text, ok := child.(string); ok && text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
default:
|
|
collect(child)
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range typed {
|
|
collect(child)
|
|
}
|
|
}
|
|
}
|
|
for _, paragraph := range result.Paragraphs {
|
|
collect(paragraph)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "", fmt.Errorf("逐字稿没有可验证的文本字段")
|
|
}
|
|
return strings.Join(parts, "\n"), nil
|
|
}
|
|
|
|
func replaceReadbackVerified(before, after string, pair replacePair, beforeSource, beforeTarget int) bool {
|
|
if before == after {
|
|
return false
|
|
}
|
|
afterSource := strings.Count(after, pair.orig)
|
|
if pair.repl == "" {
|
|
return afterSource < beforeSource
|
|
}
|
|
afterTarget := strings.Count(after, pair.repl)
|
|
if strings.Contains(pair.repl, pair.orig) {
|
|
return afterTarget > beforeTarget
|
|
}
|
|
return afterSource < beforeSource && afterTarget > beforeTarget
|
|
}
|
|
|
|
func init() {
|
|
shortcut.Register(finalizeMinutesSmartShortcut(ReplaceBatch))
|
|
}
|