Files
dingtalk-workspace-cli/internal/shortcut/report/report.go
DennisandClaude Opus 4.8 b1e7b43f85 fix(shortcut): fix projection-data-loss silent-empty returns
Several read shortcuts returned an empty list with exit 0 and no error
envelope even though the underlying MCP tool returned data, so agents misread
"no data" and made wrong decisions.

Root causes:
- Container key mismatch: the resolver probed the wrong key —
  processCodeList / values / wikiSpaces / itemList / groupList / recentItems /
  emailAccounts / deptUserList / labelUserList / roles / report_list, plus
  get_org_labels grouped labels[] needing a descend.
- Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO /
  userInfo.
- Param exceeded a backend limit: todo +created-todos sent pageSize=50 while
  the backend silently returns empty for pageSize>20; now uses the shared pager
  (pageSize=20).

Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart
resolvers. Every fix ships a guard test that feeds the real backend response
shape (and, for minutes, both the taskUuid and minutesId item shapes) and
asserts the projection is non-empty with a usable id.

scripts/shortcut_real_result.py now compares the upper (projection) output
against the lower (raw backend) layer, and record_real_shortcut_run.py captures
the lower layer in memory (persisting only derived counts, never raw PII) so an
exit-0 empty projection over a non-empty backend is scored as
projection-data-loss instead of real-ok. The Python self-test runs in CI via
test/scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:07:02 +08:00

242 lines
9.2 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.
// 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 report declares declarative shortcuts for the DingTalk
// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a
// single MCP tool call; tool names and parameter keys mirror the DWS report
// helper (internal/helpers/report.go) verbatim.
package report
import (
"fmt"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00)
// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior.
func reportParseISOMillis(name, s string) (int64, error) {
s = strings.TrimSpace(s)
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err)
}
return t.UnixMilli(), nil
}
// TemplateList lists the report templates available to the current user.
// TemplateGet reads a single report template's field definitions by name.
// InboxList lists reports received by the current user within a time window.
var InboxList = shortcut.Shortcut{
Service: "report",
Command: "+inbox-list",
Product: "report",
Description: "列出我收到的日报(按时间范围分页)",
Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"},
},
Tips: []string{
`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
startMs, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
return err
}
endMs, err := reportParseISOMillis("end", rt.Str("end"))
if err != nil {
return err
}
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
}
if rt.Changed("sender-user-ids") {
params["senderUserIds"] = rt.StrSlice("sender-user-ids")
}
data, err := rt.CallMCPData("report", "get_received_report_list", params)
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
},
}
// reportEntryListProject reshapes the raw report-list responses
// (get_received_report_list / get_send_report_list) into a clean, stable list
// of report summaries — the output-projection fidelity the framework applies to
// every list command. Both the list container and the per-item field names are
// probed defensively across candidate keys so the projection tolerates
// response-shape drift and never fabricates data: an empty/unknown shape yields
// an empty list rather than a crash.
func reportEntryListProject(data map[string]any) []map[string]any {
raw := reportEntryListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok {
row["reportId"] = v
}
if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok {
row["templateName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok {
row["creatorName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok {
row["creatorUserId"] = v
}
if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok {
row["createTime"] = v
}
if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok {
row["modifiedTime"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// reportEntryListResolveList locates the list payload inside the response,
// tolerating a bare top-level array or nesting one level under a common
// envelope key.
func reportEntryListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); "report_list" MUST be probed — the
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
// empty despite the backend returning reports.
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// reportEntryListFirst returns the first present candidate key's value.
func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// OutboxList lists reports sent/created by the current user.
var OutboxList = shortcut.Shortcut{
Service: "report",
Command: "+outbox-list",
Product: "report",
Description: "列出我发出的日报(可选时间/模版名过滤)",
Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"},
{Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"},
{Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"},
{Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"},
},
Tips: []string{
`dws report +outbox-list --cursor 0 --size 20`,
`dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
}
if rt.Changed("start") {
ms, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
return err
}
params["startTime"] = ms
}
if rt.Changed("end") {
ms, err := reportParseISOMillis("end", rt.Str("end"))
if err != nil {
return err
}
params["endTime"] = ms
}
if rt.Changed("modified-start") {
ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start"))
if err != nil {
return err
}
params["modifiedStartTime"] = ms
}
if rt.Changed("modified-end") {
ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end"))
if err != nil {
return err
}
params["modifiedEndTime"] = ms
}
if rt.Changed("template-name") {
params["report_template_name"] = rt.Str("template-name")
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
},
}
// EntryGet reads the full body of a single report by id.
// EntryStats reads the read-receipt statistics of a single report.
// EntrySubmit submits a new report against a template.
func init() {
shortcut.Register(
InboxList,
OutboxList,
)
}