Files
dingtalk-workspace-cli/internal/helpers/docjsonml/doc_jsonml_normalize.go
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

214 lines
7.1 KiB
Go
Raw Permalink 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 docjsonml
import (
"fmt"
"github.com/google/uuid"
)
// ──────────────────────────────────────────────────────────
// JSONML 轻量 Normalize / Auto-fix
//
// 目标:covering 80/20 of agent mistakes that the validator surfaces.
// 重型修复 (table/list 复杂结构) 由服务端 packSerializer.deserialize 兜底,
// 此处不复刻。
//
// 已实现的修复 (spec §3.4):
//
// 1. 单 block 作为 body 传入 → 包成 `[[block]]` 数组形态。
// 2. block 节点 attrs 缺 uuid → 注入随机 uuid。
// 3. text-bearing block (p, h1-h6) 子节点是裸字符串 →
// 包装成 `[span,{data-type:text},[span,{data-type:leaf},STR]]`。
//
// 未实现 / 故意不做:
//
// - 未知 tag 改写:可能误伤;validator 给最相似 tag 提示即可。
// - 必填属性默认值:当前没有合理可注入的默认值 (toc styles / container.subType
// / table.colsWidth 都需要业务判断)。
// - uuid 注入到「已显式给出空 attrs({})」的 block:视为生产者明确意图,
// 尊重之;只在 attrs 槽完全缺失(如 `["p", "text"]`)时才创建 attrs 并
// 注入 uuid。真实 `doc read` 输出中常见 `["h1", {}, ...]` 形态,注入会
// 污染 doc-read → doc-update 回灌。
//
// 关注点分离:
//
// 缺 root wrapper 的修整(["root", {}, ...])由 EnsureRootWrappedBody 在
// body 整体形态层面处理,不在本函数职责内 —— 这样 per-block 修复测试矩阵
// 不被协议层包装污染。
// ──────────────────────────────────────────────────────────
// NormalizeJsonMLBody returns a deep-cloned body with safe auto-fixes
// applied, plus human-readable notes describing every change.
//
// notes is empty when no fix was needed. fixed is the parsed JSON-friendly
// payload ready to be marshaled.
func NormalizeJsonMLBody(body []any) (fixed []any, notes []string) {
working, ok := deepCloneAny(body).([]any)
if !ok || len(working) == 0 {
return working, nil
}
var collected []string
// Fix #1 — single block passed as body.
// "root" is a structural wrapper (handled by the explicit branch below),
// not a single block to be wrapped — excluding it prevents double-wrap when
// the schema-driven validBlockTags set lists "root" alongside real blocks.
if tag, ok := working[0].(string); ok && tag != "root" && validBlockTags[tag] {
collected = append(collected,
fmt.Sprintf("$: wrap single %q block as body array", tag))
working = []any{working}
}
startIdx := 0
if tag, ok := working[0].(string); ok && tag == "root" {
// Root-wrapped: skip ["root", {attrs}] and recurse into children.
startIdx = 1
if len(working) > 1 {
if _, attrsOk := working[1].(map[string]any); attrsOk {
startIdx = 2
}
}
}
for i := startIdx; i < len(working); i++ {
fixedChild, childNotes := normalizeBlock(working[i], fmt.Sprintf("$[%d]", i))
working[i] = fixedChild
collected = append(collected, childNotes...)
}
return working, collected
}
// NormalizeJsonMLNode normalizes a single block node (used by
// `doc block insert/update --format jsonml --element <node>`).
func NormalizeJsonMLNode(node any) (fixed any, notes []string) {
cloned := deepCloneAny(node)
return normalizeBlock(cloned, "$")
}
// normalizeBlock applies fixes #2 and #3 in-place on a single block node.
// The caller is responsible for handing in a deep-cloned subtree.
func normalizeBlock(node any, path string) (any, []string) {
arr, ok := node.([]any)
if !ok || len(arr) == 0 {
return node, nil
}
tag, ok := arr[0].(string)
if !ok {
return arr, nil
}
var notes []string
// Fix #2 — inject uuid ONLY when attrs slot is completely missing.
//
// 设计:尊重生产者显式给出的 attrs 形态。
// - `["p", "text"]` → attrs 槽缺失,agent 漏写,补 attrs+uuid ✓
// - `["p", {}, ...]` → 已显式给出空 attrs,不动(真实 doc-read 输出常态)
// - `["p", {"jc":"left"}]` → attrs 存在但无 uuid,亦不补(生产者既然写了 attrs,应该自己负责 uuid)
// 这样保证 doc-read → doc-update roundtrip 不会污染原文档节点。
if validBlockTags[tag] && len(arr) > 1 {
if _, hadAttrs := arr[1].(map[string]any); !hadAttrs {
attrs := map[string]any{"uuid": newUUID()}
arr = append([]any{arr[0], attrs}, arr[1:]...)
notes = append(notes,
fmt.Sprintf("%s: insert attrs slot with generated uuid %q", path, attrs["uuid"]))
}
}
childStart := childStartIndex(arr)
// Fix #3 — text-bearing blocks: wrap raw-string children.
if isTextBearingBlock(tag) {
for i := childStart; i < len(arr); i++ {
if s, isString := arr[i].(string); isString {
wrapped := wrapTextLeaf(s)
arr[i] = wrapped
notes = append(notes,
fmt.Sprintf("%s[%d]: wrap raw string into span/text/leaf", path, i))
}
}
}
// Recurse into children that are themselves block nodes (container, refblock, table…)
for i := childStart; i < len(arr); i++ {
child, ok := arr[i].([]any)
if !ok {
continue
}
if len(child) == 0 {
continue
}
childTag, ok := child[0].(string)
if !ok {
continue
}
// Recurse into block children (container, refblock, table rows/cells —
// `tr`/`tc` are members of validBlockTags via the schema).
switch {
case validBlockTags[childTag]:
fixed, childNotes := normalizeBlock(child, fmt.Sprintf("%s[%d]", path, i))
arr[i] = fixed
notes = append(notes, childNotes...)
}
}
return arr, notes
}
// isTextBearingBlock returns true for block tags whose direct children are
// inline content (and where a bare string is fixable by span-wrapping).
//
// container/refblock/table take block children, not inline — bare strings
// there are a different kind of error and are NOT auto-wrapped.
func isTextBearingBlock(tag string) bool {
switch tag {
case "p", "h1", "h2", "h3", "h4", "h5", "h6":
return true
}
return false
}
// wrapTextLeaf builds the canonical text wrapper around a raw string.
//
// ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<s>"]]
func wrapTextLeaf(s string) []any {
return []any{
"span",
map[string]any{"data-type": "text"},
[]any{
"span",
map[string]any{"data-type": "leaf"},
s,
},
}
}
// newUUID returns a standard RFC 4122 v4 uuid string (e.g.
// "550e8400-e29b-41d4-a716-446655440000"). The server reassigns uuids on
// insert anyway; this exists only to satisfy validators and let agents
// track newly-inserted blocks before the server roundtrip.
func newUUID() string {
return uuid.NewString()
}
// deepCloneAny clones JSON-shaped values (map[string]any, []any, primitives).
// Required so normalize does not mutate caller-owned input.
func deepCloneAny(v any) any {
switch x := v.(type) {
case []any:
out := make([]any, len(x))
for i, e := range x {
out[i] = deepCloneAny(e)
}
return out
case map[string]any:
out := make(map[string]any, len(x))
for k, val := range x {
out[k] = deepCloneAny(val)
}
return out
default:
return v
}
}