Compare commits

...

5 Commits

Author SHA1 Message Date
zgz2048
04932c2421 feat: add base record filter and sort json flags (#1228)
* feat: add base record filter and sort json flags

* test: cover base record query flags
2026-06-02 22:02:56 +08:00
liangshuo-1
531d7265b5 chore(release): v1.0.46 (#1229) 2026-06-02 21:58:26 +08:00
91-enjoy
6d7f8ba442 feat: im card message format (#1218)
Interactive card messages (msg_type: interactive) can contain @user elements in their card
body. The json_attachment.at_users field stores resolved user info, but the user_id there is
the sender-side platform user_id — not the reading app's canonical open_id. When the backend
populates a mention_key on each at_users entry, it signals that the API-level mentions[]
array carries a more authoritative open_id and display name for the reading context. This PR adds
support for this two-level lookup: it threads the raw mentions[] array into the card converter,
indexes it by mention_key for O(1) access, and renders the canonical open_id + display name
whenever the link is resolvable. All existing fallback paths (no mention_key, nil mentions) are
preserved without behavioral change.

Change-Id: I00f846d76482adba315d07361c35909b71ca74c7
2026-06-02 20:42:59 +08:00
liangshuo-1
b216363e63 fix(cli): remove FLAGS section from root --help (#1226)
Follow-up to #1223. The hand-written FLAGS block in `lark-cli --help`
restated leaf-command flags at the root level — flags that are not
registered on the root command (they error "unknown flag" there). Even
trimmed to an illustrative example list, it duplicated information Cobra's
per-command `--help` already renders authoritatively, and any static list
in root help drifts from the real per-command flag sets over time.

Drop the section entirely: Cobra's per-command `Flags:` output is the
single source of truth. `USAGE:`/`EXAMPLES:` still show flags in context,
and the `Flags:` block at the bottom of root help lists the actual root
flags. Also removes the now-obsolete TestRootLong_FlagsSectionPointsToCommandHelp.
2026-06-02 20:31:45 +08:00
liangshuo-1
b0b163d0ef fix(cli): stop root --help listing per-command flags as global (#1223)
The hand-written FLAGS block in `lark-cli --help` listed --params, --data,
--as, --format, --page-all, --page-size, --page-limit, --page-delay, -o,
--jq, -q and --dry-run as if they were global flags. None are registered
on the root command — they all error "unknown flag" at the top level and
exist only on leaf commands (api, service). The block also contradicted
the Cobra-generated "Flags:" section rendered directly below it, which
shows only -h/--help, --profile, -v/--version.

Replace it with a short illustrative example list (common flags first) and
a pointer to `lark-cli <command> --help` for the full per-command set.
Root help stays a discovery signpost without claiming the flags are global
or restating defaults/descriptions that drift from the real flag sets.

Change-Id: Ia1cab889dd70b6b49a61dac468dedfd7fe39043f
2026-06-02 20:11:20 +08:00
20 changed files with 1077 additions and 125 deletions

View File

@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.46] - 2026-06-02
### Features
- **im**: Add card message format support (#1218)
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
- **agent**: Increase agent trace max length to 1024 (#1211)
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
### Bug Fixes
- **cli**: Remove FLAGS section from root `--help` (#1226)
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
### Refactor
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
### Documentation
- **base**: Optimize base skill references (#1171)
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
## [v1.0.45] - 2026-06-01 ## [v1.0.45] - 2026-06-01
### Features ### Features
@@ -964,6 +989,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45 [v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44 [v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43 [v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43

View File

@@ -48,20 +48,6 @@ EXAMPLES:
# Generic API call # Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars lark-cli api GET /open-apis/calendar/v4/calendars
FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--as <type> identity type: user | bot
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
--page-size <N> page size (0 = use API default)
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--jq <expr> jq expression to filter JSON output
-q <expr> shorthand for --jq
--dry-run print request without executing
AI AGENT SKILLS: AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows. teach the agent Lark API patterns, best practices, and workflows.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@larksuite/cli", "name": "@larksuite/cli",
"version": "1.0.45", "version": "1.0.46",
"description": "The official CLI for Lark/Feishu open platform", "description": "The official CLI for Lark/Feishu open platform",
"bin": { "bin": {
"lark-cli": "scripts/run.js" "lark-cli": "scripts/run.js"

View File

@@ -71,6 +71,29 @@ func TestDryRunRecordOps(t *testing.T) {
) )
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age") assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
filteredListRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
"sort-json": `[{"field":"Due","desc":true}]`,
},
nil,
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(
t,
dryRunRecordList(ctx, filteredListRT),
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
"limit=20",
"filter=%7B",
"Status",
"Todo",
"sort=%5B",
"Due",
)
commaFieldRT := newBaseTestRuntimeWithArrays( commaFieldRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-id": {"A,B", "C"}}, map[string][]string{"field-id": {"A,B", "C"}},
@@ -99,6 +122,33 @@ func TestDryRunRecordOps(t *testing.T) {
`"limit":500`, `"limit":500`,
) )
searchFlagRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"keyword": "Alice",
"view-id": "viw_1",
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
"sort-json": `[{"field":"Updated At","desc":true}]`,
},
map[string][]string{
"search-field": {"Name"},
"field-id": {"Name", "Status"},
},
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(
t,
dryRunRecordSearch(ctx, searchFlagRT),
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
`"keyword":"Alice"`,
`"search_fields":["Name"]`,
`"select_fields":["Name","Status"]`,
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
`"sort":[{"desc":true,"field":"Updated At"}]`,
)
upsertCreateRT := newBaseTestRuntime( upsertCreateRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
nil, nil, nil, nil,

View File

@@ -974,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"+record-search", "+record-search",
"--base-token", "app_x", "--base-token", "app_x",
"--table-id", "tbl_x", "--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`, "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
"--format", "json", "--format", "json",
}, },
factory, factory,
@@ -990,12 +990,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
!strings.Contains(body, `"keyword":"Created"`) || !strings.Contains(body, `"keyword":"Created"`) ||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
!strings.Contains(body, `"offset":0`) || !strings.Contains(body, `"offset":0`) ||
!strings.Contains(body, `"limit":2`) { !strings.Contains(body, `"limit":2`) {
t.Fatalf("captured body=%s", body) t.Fatalf("captured body=%s", body)
} }
}) })
t.Run("search with flag filter sort and projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Status"},
"field_id_list": []interface{}{"fld_title", "fld_status"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--keyword", "Created",
"--search-field", "Title",
"--field-id", "Title",
"--field-id", "Status",
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
"--limit", "20",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
}
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
t.Fatalf("captured body=%#v", body)
}
filter := body["filter"].(map[string]interface{})
if filter["logic"] != "and" {
t.Fatalf("filter=%#v", filter)
}
conditions := filter["conditions"].([]interface{})
if len(conditions) != 2 {
t.Fatalf("conditions=%#v", conditions)
}
sortConfig := body["sort"].([]interface{})
if len(sortConfig) != 2 {
t.Fatalf("sort=%#v", sortConfig)
}
firstSort := sortConfig[0].(map[string]interface{})
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
t.Fatalf("sort=%#v", sortConfig)
}
})
t.Run("search with filter json file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmp := t.TempDir()
withBaseWorkingDir(t, tmp)
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
t.Fatalf("write filter err=%v", err)
}
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--keyword", "A",
"--search-field", "Title",
"--filter-json", "@filter.json",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
body := string(searchStub.CapturedBody)
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
t.Fatalf("captured body=%s", body)
}
})
t.Run("search markdown format", func(t *testing.T) { t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{

View File

@@ -254,35 +254,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
wantHelp: []string{ wantHelp: []string{
"field ID or name to include; repeat to project only needed fields", "field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view", "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
`filter JSON object or @file`,
`sort JSON array or @file`,
"pagination size, range 1-200", "pagination size, range 1-200",
"output format: markdown (default) | json", "output format: markdown (default) | json",
}, },
wantTips: []string{ wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50", "lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50", "lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Text equality filter",
"Option intersection filter",
"Query priority",
"Default output is markdown", "Default output is markdown",
"Use --field-id repeatedly to keep output small", "Use --field-id repeatedly to keep output small",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
"lark-base record read SOP",
}, },
}, },
{ {
name: "record search", name: "record search",
shortcut: BaseRecordSearch, shortcut: BaseRecordSearch,
wantHelp: []string{ wantHelp: []string{
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`, `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
"for keyword search only", "keyword for record search",
"field ID or name to search",
`filter JSON object or @file`,
`sort JSON array or @file`,
"output format: markdown (default) | json", "output format: markdown (default) | json",
}, },
wantTips: []string{ wantTips: []string{
"Happy path fields: keyword (string), search_fields", "Example: lark-cli base +record-search",
"search_fields length 1-20", "Example with filter/sort JSON",
"limit range 1-200 defaults to 10", "Text equality filter",
"view_id scopes search to records in that view", "Query priority",
"Use --json only when you need to pass the full search body directly",
"Default output is markdown", "Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
"inventing search JSON",
}, },
}, },
{ {
@@ -607,7 +611,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record search json", name: "record search json",
shortcut: BaseRecordSearch, shortcut: BaseRecordSearch,
wantHelp: []string{ wantHelp: []string{
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`, `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
}, },
}, },
{ {
@@ -885,11 +889,11 @@ func TestBaseTableValidate(t *testing.T) {
func TestBaseRecordValidate(t *testing.T) { func TestBaseRecordValidate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
if BaseRecordList.Validate != nil { if BaseRecordList.Validate == nil {
t.Fatalf("record list validate should be nil for repeatable --field-id") t.Fatalf("record list validate should reject invalid query flags before dry-run")
} }
if BaseRecordSearch.Validate == nil { if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run") t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
} }
if BaseRecordGet.Validate == nil { if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run") t.Fatalf("record get validate should reject invalid record selection before dry-run")
@@ -900,6 +904,58 @@ func TestBaseRecordValidate(t *testing.T) {
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil { if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
t.Fatalf("record upsert map validate err=%v", err) t.Fatalf("record upsert map validate err=%v", err)
} }
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
nil,
nil,
)); err != nil {
t.Fatalf("record list filter-json validate err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
nil,
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
"sort-json": `[{"field":"Title","desc":false}]`,
},
nil,
nil,
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseViewValidate(t *testing.T) { func TestBaseViewValidate(t *testing.T) {

View File

@@ -22,6 +22,8 @@ var BaseRecordList = common.Shortcut{
tableRefFlag(true), tableRefFlag(true),
recordListFieldRefFlag(), recordListFieldRefFlag(),
recordListViewRefFlag(), recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(), recordReadFormatFlag(),
@@ -29,10 +31,21 @@ var BaseRecordList = common.Shortcut{
Tips: []string{ Tips: []string{
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50", "Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50", "Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
`Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`,
`Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`,
`Number equality filter: --filter-json '{"logic":"and","conditions":[["Score","==",95]]}'`,
`Date equality filter: --filter-json '{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}'`,
`Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`,
`Sort priority follows --sort-json array order: --sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`,
formatRecordQueryPriorityTip(),
"Default output is markdown; pass --format json to get the raw JSON envelope.", "Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id repeatedly to keep output small and aligned with the task.", "Use --field-id repeatedly to keep output small and aligned with the task.",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.", },
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.", Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
}, },
DryRun: dryRunRecordList, DryRun: dryRunRecordList,
PostMount: func(cmd *cobra.Command) { PostMount: func(cmd *cobra.Command) {

View File

@@ -217,6 +217,9 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
if viewID := runtime.Str("view-id"); viewID != "" { if viewID := runtime.Str("view-id"); viewID != "" {
params.Set("view_id", viewID) params.Set("view_id", viewID)
} }
if err := applyRecordQueryToURLValues(runtime, params); err != nil {
return common.NewDryRunAPI()
}
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode() path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
return common.NewDryRunAPI(). return common.NewDryRunAPI().
GET(path). GET(path).
@@ -237,8 +240,12 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
} }
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime) var body map[string]interface{}
body, _ := parseJSONObject(pc, runtime.Str("json"), "json") if strings.TrimSpace(runtime.Str("json")) != "" {
body, _ = recordSearchJSONBody(runtime)
} else {
body, _ = recordSearchFlagBody(runtime)
}
return common.NewDryRunAPI(). return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search"). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
Body(body). Body(body).
@@ -388,6 +395,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
if viewID := runtime.Str("view-id"); viewID != "" { if viewID := runtime.Str("view-id"); viewID != "" {
params["view_id"] = viewID params["view_id"] = viewID
} }
if err := applyRecordQueryToParams(runtime, params); err != nil {
return err
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil)
if err != nil { if err != nil {
return err return err
@@ -420,8 +430,13 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
} }
func executeRecordSearch(runtime *common.RuntimeContext) error { func executeRecordSearch(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime) var body map[string]interface{}
body, err := parseJSONObject(pc, runtime.Str("json"), "json") var err error
if strings.TrimSpace(runtime.Str("json")) != "" {
body, err = recordSearchJSONBody(runtime)
} else {
body, err = recordSearchFlagBody(runtime)
}
if err != nil { if err != nil {
return err return err
} }

View File

@@ -0,0 +1,248 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const (
recordFilterJSONFlag = "filter-json"
recordSortJSONFlag = "sort-json"
recordSortMaxCount = 10
)
func recordFilterFlag() common.Flag {
return common.Flag{
Name: recordFilterJSONFlag,
Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`,
Input: []string{common.File},
}
}
func recordSortFlag() common.Flag {
return common.Flag{
Name: recordSortJSONFlag,
Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`,
Input: []string{common.File},
}
}
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
if _, err := parseRecordFilterFlag(runtime); err != nil {
return err
}
_, err := parseRecordSortFlag(runtime)
return err
}
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
if filterRaw == "" {
return nil, nil
}
pc := newParseCtx(runtime)
return parseJSONObject(pc, filterRaw, recordFilterJSONFlag)
}
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
if sortRaw == "" {
return nil, nil
}
pc := newParseCtx(runtime)
value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag)
if err != nil {
return nil, err
}
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
}
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
var sortConfig []interface{}
if parsed, ok := value.([]interface{}); ok {
sortConfig = parsed
} else if obj, ok := value.(map[string]interface{}); ok {
rawSortConfig, ok := obj["sort_config"]
if !ok {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
parsed, ok := rawSortConfig.([]interface{})
if !ok {
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
}
sortConfig = parsed
} else {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
if len(sortConfig) > recordSortMaxCount {
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
}
return sortConfig, nil
}
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
}
return string(data), nil
}
func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
if err != nil {
return err
}
params["filter"] = filterJSON
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
if err != nil {
return err
}
params["sort"] = sortJSON
}
return nil
}
func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
if err != nil {
return err
}
params["filter"] = []string{filterJSON}
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
if err != nil {
return err
}
params["sort"] = []string{sortJSON}
}
return nil
}
func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
body["filter"] = filter
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
body["sort"] = sortConfig
}
return nil
}
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
body["keyword"] = keyword
}
searchFields := runtime.StrArray("search-field")
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
selectFields := recordListFields(runtime)
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
if viewID := runtime.Str("view-id"); viewID != "" {
body["view_id"] = viewID
}
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
body["offset"] = offset
body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200)
return body, applyRecordQueryToBody(runtime, body)
}
func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return nil, err
}
if err := normalizeRecordSearchJSONBody(body); err != nil {
return nil, err
}
return body, applyRecordQueryToBody(runtime, body)
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
} else {
return err
}
}
return nil
}
func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
}
_, err := recordSearchJSONBody(runtime)
return err
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return common.FlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
return common.FlagErrorf("--search-field is required unless --json is used")
}
return validateRecordQueryOptions(runtime)
}
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
runtime.Changed("offset") ||
runtime.Changed("limit")
}
func formatRecordQueryPriorityTip() string {
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"net/url"
"strings"
"testing"
)
func TestNormalizeRecordSortValue(t *testing.T) {
t.Run("array", func(t *testing.T) {
sortConfig, err := normalizeRecordSortValue([]interface{}{
map[string]interface{}{"field": "Updated", "desc": true},
}, "--sort-json")
if err != nil {
t.Fatalf("err=%v", err)
}
if len(sortConfig) != 1 {
t.Fatalf("sortConfig=%#v", sortConfig)
}
})
t.Run("wrapped sort_config", func(t *testing.T) {
sortConfig, err := normalizeRecordSortValue(map[string]interface{}{
"sort_config": []interface{}{
map[string]interface{}{"field": "Updated", "desc": false},
},
}, "--json.sort")
if err != nil {
t.Fatalf("err=%v", err)
}
first := sortConfig[0].(map[string]interface{})
if first["field"] != "Updated" || first["desc"] != false {
t.Fatalf("sortConfig=%#v", sortConfig)
}
})
t.Run("invalid wrapper", func(t *testing.T) {
_, err := normalizeRecordSortValue(map[string]interface{}{"sort": []interface{}{}}, "--sort-json")
if err == nil || !strings.Contains(err.Error(), "sort_config array") {
t.Fatalf("err=%v", err)
}
})
t.Run("invalid sort_config type", func(t *testing.T) {
_, err := normalizeRecordSortValue(map[string]interface{}{"sort_config": "Updated"}, "--sort-json")
if err == nil || !strings.Contains(err.Error(), "--sort-json.sort_config must be a JSON array") {
t.Fatalf("err=%v", err)
}
})
t.Run("invalid scalar", func(t *testing.T) {
_, err := normalizeRecordSortValue("Updated", "--sort-json")
if err == nil || !strings.Contains(err.Error(), "must be a JSON array") {
t.Fatalf("err=%v", err)
}
})
}
func TestApplyRecordQueryToParams(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`,
"sort-json": `{"sort_config":[{"field":"Updated","desc":true}]}`,
},
nil,
nil,
)
params := map[string]interface{}{"view_id": "viw_1"}
if err := applyRecordQueryToParams(runtime, params); err != nil {
t.Fatalf("err=%v", err)
}
if params["view_id"] != "viw_1" {
t.Fatalf("params=%#v", params)
}
var filter map[string]interface{}
if err := json.Unmarshal([]byte(params["filter"].(string)), &filter); err != nil {
t.Fatalf("filter err=%v", err)
}
if filter["logic"] != "and" {
t.Fatalf("filter=%#v", filter)
}
var sortConfig []interface{}
if err := json.Unmarshal([]byte(params["sort"].(string)), &sortConfig); err != nil {
t.Fatalf("sort err=%v", err)
}
firstSort := sortConfig[0].(map[string]interface{})
if firstSort["field"] != "Updated" || firstSort["desc"] != true {
t.Fatalf("sort=%#v", sortConfig)
}
}
func TestApplyRecordQueryToURLValues(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{
"filter-json": `{"logic":"or","conditions":[["Score",">",90]]}`,
"sort-json": `[{"field":"Score","desc":false}]`,
},
nil,
nil,
)
params := url.Values{"view_id": {"viw_1"}}
if err := applyRecordQueryToURLValues(runtime, params); err != nil {
t.Fatalf("err=%v", err)
}
if got := params.Get("view_id"); got != "viw_1" {
t.Fatalf("view_id=%q", got)
}
if !strings.Contains(params.Get("filter"), `"logic":"or"`) || !strings.Contains(params.Get("sort"), `"field":"Score"`) {
t.Fatalf("params=%#v", params)
}
}
func TestRecordSearchJSONBodyAppliesQueryFlagOverrides(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{
"json": `{"keyword":"urgent","search_fields":["Title"],"filter":{"logic":"and","conditions":[["Status","==","Done"]]},"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`,
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`,
"sort-json": `[{"field":"Score","desc":true}]`,
},
nil,
nil,
)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("err=%v", err)
}
filter := body["filter"].(map[string]interface{})
conditions := filter["conditions"].([]interface{})
statusCondition := conditions[0].([]interface{})
if statusCondition[2] != "Todo" {
t.Fatalf("filter=%#v", filter)
}
sortConfig := body["sort"].([]interface{})
firstSort := sortConfig[0].(map[string]interface{})
if firstSort["field"] != "Score" || firstSort["desc"] != true {
t.Fatalf("sort=%#v", sortConfig)
}
}
func TestRecordSearchJSONBodyNormalizesWrappedSort(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{
"json": `{"keyword":"urgent","search_fields":["Title"],"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`,
},
nil,
nil,
)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("err=%v", err)
}
sortConfig := body["sort"].([]interface{})
firstSort := sortConfig[0].(map[string]interface{})
if firstSort["field"] != "Updated" || firstSort["desc"] != false {
t.Fatalf("sort=%#v", sortConfig)
}
}

View File

@@ -20,21 +20,34 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true}, {Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordListFieldRefFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(), recordReadFormatFlag(),
}, },
Tips: []string{ Tips: []string{
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`, `Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.", "JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.", "view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --keyword Alice --search-field Name --field-id Name --field-id Status --limit 20`,
`Example with filter/sort JSON: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --keyword Alice --search-field Name --filter-json @filter.json --sort-json '[{"field":"Updated","desc":true}]'`,
`Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`,
`Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`,
`Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`,
`Sort priority follows --sort-json array order.`,
formatRecordQueryPriorityTip(),
"Use +record-search for keyword matching; use --filter-json for structured conditions and --sort-json for result ordering.",
"Use --json only when you need to pass the full search body directly.",
"Default output is markdown; pass --format json to get the raw JSON envelope.", "Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil { return validateRecordSearchFlags(runtime)
return err
}
return validateRecordJSON(runtime)
}, },
DryRun: dryRunRecordSearch, DryRun: dryRunRecordSearch,
PostMount: func(cmd *cobra.Command) { PostMount: func(cmd *cobra.Command) {

View File

@@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{
type interactiveConverter struct{} type interactiveConverter struct{}
func (interactiveConverter) Convert(ctx *ConvertContext) string { func (interactiveConverter) Convert(ctx *ConvertContext) string {
return convertCard(ctx.RawContent) return convertCard(ctx.RawContent, ctx.Mentions)
} }
// convertCard converts a raw interactive/card message content JSON to human-readable string. // convertCard converts a raw interactive/card message content JSON to human-readable string.
func convertCard(raw string) string { // mentions is the raw mentions array from the API response; pass nil when not available.
func convertCard(raw string, mentions []interface{}) string {
var parsed cardObj var parsed cardObj
if err := json.Unmarshal([]byte(raw), &parsed); err != nil { if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return "[interactive card]" return "[interactive card]"
@@ -63,11 +64,19 @@ func convertCard(raw string) string {
// raw_card_content format: outer JSON has "json_card" string field // raw_card_content format: outer JSON has "json_card" string field
if jsonCard, ok := parsed["json_card"].(string); ok { if jsonCard, ok := parsed["json_card"].(string); ok {
c := &cardConverter{mode: cardModeConcise} c := &cardConverter{mode: cardModeConcise}
if att, ok := parsed["json_attachment"].(string); ok && att != "" { switch att := parsed["json_attachment"].(type) {
var attObj cardObj case string:
if json.Unmarshal([]byte(att), &attObj) == nil { if att != "" {
c.attachment = attObj var attObj cardObj
if json.Unmarshal([]byte(att), &attObj) == nil {
c.attachment = attObj
}
} }
case cardObj:
c.attachment = att
}
if len(mentions) > 0 {
c.mentionsByKey = buildMentionsByKey(mentions)
} }
schema := 0 schema := 0
if s, ok := parsed["card_schema"].(float64); ok { if s, ok := parsed["card_schema"].(float64); ok {
@@ -84,6 +93,22 @@ func convertCard(raw string) string {
return convertLegacyCard(parsed) return convertLegacyCard(parsed)
} }
// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt.
func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} {
m := make(map[string]map[string]interface{}, len(mentions))
for _, raw := range mentions {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
key, _ := item["key"].(string)
if key != "" {
m[key] = item
}
}
return m
}
// ── Legacy converter ────────────────────────────────────────────────────────── // ── Legacy converter ──────────────────────────────────────────────────────────
func convertLegacyCard(parsed cardObj) string { func convertLegacyCard(parsed cardObj) string {
@@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) {
// ── CardConverter ───────────────────────────────────────────────────────────── // ── CardConverter ─────────────────────────────────────────────────────────────
type cardConverter struct { type cardConverter struct {
mode cardMode mode cardMode
attachment cardObj attachment cardObj
mentionsByKey map[string]map[string]interface{}
} }
func (c *cardConverter) convert(jsonCard string, hintSchema int) string { func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
@@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string {
} }
userName := "" userName := ""
actualUserID := "" actualUserID := ""
fromMentions := false
if c.attachment != nil { if c.attachment != nil {
if atUsers, ok := c.attachment["at_users"].(cardObj); ok { if atUsers, ok := c.attachment["at_users"].(cardObj); ok {
if userInfo, ok := atUsers[userID].(cardObj); ok { if userInfo, ok := atUsers[userID].(cardObj); ok {
userName, _ = userInfo["content"].(string) userName, _ = userInfo["content"].(string)
actualUserID, _ = userInfo["user_id"].(string) actualUserID, _ = userInfo["user_id"].(string)
// When the backend populates mention_key (raw_card_content path), use
// mentions[] for the canonical name and the reading-app open_id, which is
// more accurate than the origKey-stored user_id in at_users.
if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" {
if mention, ok := c.mentionsByKey[mentionKey]; ok {
if name, _ := mention["name"].(string); name != "" {
userName = name
}
if id := extractMentionOpenId(mention["id"]); id != "" {
actualUserID = id
fromMentions = true
}
}
}
} }
} }
} }
if userName != "" { if userName != "" {
if c.mode == cardModeDetailed { if c.mode == cardModeDetailed {
if actualUserID != "" { if actualUserID != "" {
return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID) label := "user_id"
if fromMentions {
label = "open_id"
}
return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID)
} }
return fmt.Sprintf("@%s(open_id:%s)", userName, userID) return fmt.Sprintf("@%s(open_id:%s)", userName, userID)
} }
return "@" + userName if fromMentions && actualUserID != "" {
return fmt.Sprintf("@%s(%s)", userName, actualUserID)
}
return fmt.Sprintf("@%s(%s)", userName, userID)
} }
if c.mode == cardModeDetailed { if c.mode == cardModeDetailed {
if actualUserID != "" { if actualUserID != "" {
return fmt.Sprintf("@user(user_id:%s)", actualUserID) label := "user_id"
if fromMentions {
label = "open_id"
}
return fmt.Sprintf("@user(%s:%s)", label, actualUserID)
} }
return fmt.Sprintf("@user(open_id:%s)", userID) return fmt.Sprintf("@user(open_id:%s)", userID)
} }

View File

@@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter {
func TestConvertCard(t *testing.T) { func TestConvertCard(t *testing.T) {
rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}` rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}`
got := convertCard(rawCard) got := convertCard(rawCard, nil)
want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>" want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
if got != want { if got != want {
t.Fatalf("convertCard(json_card) = %q, want %q", got, want) t.Fatalf("convertCard(json_card) = %q, want %q", got, want)
} }
legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}` legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}`
gotLegacy := convertCard(legacy) gotLegacy := convertCard(legacy, nil)
wantLegacy := "**Legacy Card**\nlegacy body" wantLegacy := "**Legacy Card**\nlegacy body"
if gotLegacy != wantLegacy { if gotLegacy != wantLegacy {
t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy)
@@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) {
} }
} }
func TestConvertAtWithMentions(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"id": "ou_6b64bef911a5a3ea763df8ffd9258f59",
"name": "燕忠毅",
},
}
attachment := cardObj{
"at_users": cardObj{
"cde8a6c8": cardObj{
"user_id": "754700000001",
"content": "燕忠毅",
"mention_key": "@_user_1",
},
},
}
// Concise mode: should show @Name(open_id) when mention resolves.
concise := &cardConverter{
mode: cardModeConcise,
attachment: attachment,
mentionsByKey: buildMentionsByKey(mentions),
}
if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" {
t.Fatalf("convertAt(concise with mentions) = %q", got)
}
// Detailed mode: label should be open_id when resolved from mentions.
detailed := &cardConverter{
mode: cardModeDetailed,
attachment: attachment,
mentionsByKey: buildMentionsByKey(mentions),
}
if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" {
t.Fatalf("convertAt(detailed with mentions) = %q", got)
}
// No mention_key: falls back to at_users.user_id with user_id label (existing behavior).
noMentionKey := &cardConverter{
mode: cardModeDetailed,
attachment: cardObj{
"at_users": cardObj{
"ou_at": cardObj{"content": "Bob", "user_id": "u_bob"},
},
},
}
if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" {
t.Fatalf("convertAt(fallback no mention_key) = %q", got)
}
// mention_key present but mentionsByKey nil: still falls back gracefully.
nilMentions := &cardConverter{
mode: cardModeDetailed,
attachment: cardObj{
"at_users": cardObj{
"cde8a6c8": cardObj{
"user_id": "754700000001",
"content": "燕忠毅",
"mention_key": "@_user_1",
},
},
},
}
if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" {
t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got)
}
}
func TestCardConverterExtractTextHelpers(t *testing.T) { func TestCardConverterExtractTextHelpers(t *testing.T) {
c := newTestCardConverter(cardModeDetailed) c := newTestCardConverter(cardModeDetailed)

View File

@@ -25,6 +25,9 @@ type ContentConverter interface {
type ConvertContext struct { type ConvertContext struct {
RawContent string RawContent string
MentionMap map[string]string MentionMap map[string]string
// Mentions is the raw mentions array from the API response.
// Used by interactive card converter to resolve @user references via mention_key.
Mentions []interface{}
// MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API. // MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API.
// For other message types these can be zero values. // For other message types these can be zero values.
MessageID string MessageID string
@@ -93,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf
content := ConvertBodyContent(msgType, &ConvertContext{ content := ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent, RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions), MentionMap: BuildMentionKeyMap(mentions),
Mentions: mentions,
MessageID: messageID, MessageID: messageID,
}) })
@@ -153,6 +157,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
content = ConvertBodyContent(msgType, &ConvertContext{ content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent, RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions), MentionMap: BuildMentionKeyMap(mentions),
Mentions: mentions,
MessageID: messageId, MessageID: messageId,
Runtime: runtime, Runtime: runtime,
SenderNames: nameCache, SenderNames: nameCache,

View File

@@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str
content = ConvertBodyContent(msgType, &ConvertContext{ content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent, RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions), MentionMap: BuildMentionKeyMap(mentions),
Mentions: mentions,
}) })
} }

View File

@@ -325,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) {
t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got) t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got)
} }
} }
func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) {
cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}`
items := []map[string]interface{}{
{
"message_id": "om_card",
"msg_type": "interactive",
"create_time": "1710500000000",
"sender": map[string]interface{}{"name": "Sender"},
"body": map[string]interface{}{"content": cardContent},
"mentions": []interface{}{
map[string]interface{}{
"key": "@_user_1",
"id": "ou_real_open_id",
"name": "Alice",
},
},
},
}
children := BuildMergeForwardChildrenMap(items, "om_root")
got := FormatMergeForwardSubTree("om_root", children)
if !strings.Contains(got, "@Alice(ou_real_open_id)") {
t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got)
}
}

View File

@@ -43,28 +43,28 @@ metadata:
| 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` |
| 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` | | 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` |
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID删除前确认目标字段 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID删除前确认目标字段 |
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 `lark-base-field-json.md`;公式读 `formula-field-guide.md`lookup 读 `lookup-field-guide.md`;命令细节读 `lark-base-field-create.md` / `lark-base-field-update.md` | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md)lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) |
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 `lark-base-data-analysis-sop.md` | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) |
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读对应 record reference`lark-base-cell-value.md` | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md)[lark-base-cell-value.md](references/lark-base-cell-value.md) |
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue上传走本地文件下载/删除按 file token 或字段定位 | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue上传走本地文件下载/删除按 file token 或字段定位 |
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record分享链接最多 100 条;历史读 `lark-base-record-history-list.md`,只查单条记录,不做整表审计 | | 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
| 管理视图 | `+view-*` | `+view-set-filter``lark-base-view-set-filter.md`;其余配置先 get 现状,再按返回结构更新 | | 管理视图 | `+view-*` | `+view-set-filter`[lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
| 一次性聚合统计 | `+data-query` | 必读 `lark-base-data-analysis-sop.md` 和入口 `lark-base-data-query-guide.md`;完整 DSL 再读 `lark-base-data-query.md` | | 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 `formula-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` | | 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 `lookup-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` | | Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 `lark-base-form-detail.md` 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 `lark-base-form-submit.md` | | 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读对应 form-questions reference | | 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail``lark-base-form-detail.md`;删除前确认目标表单 | | 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail`[lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 `lark-base-dashboard.md`;组件 `data_config``dashboard-block-data-config.md`;读取图表计算结果用 `+dashboard-block-get-data` | | 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config`[dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`list/get/enable/disable 只处理 workflow ID 与启停状态 | | Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 `lark-base-role-guide.md`;角色 create/update 或解读完整配置再读权限 JSON SSOT `role-config.md`;系统角色不可删除;关闭高级权限会影响自定义角色 | | 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
## Base 心智模型 ## Base 心智模型
- Base 曾用名 Bitable返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 - Base 曾用名 Bitable返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
- 存储字段可写;系统字段、`formula``lookup` 只读;附件字段走专用 attachment 命令。 - 存储字段可写;系统字段、`formula``lookup` 只读;附件字段走专用 attachment 命令。
- 一次性统计、筛选、TopN 优先用 `+data-query` 或临时视图;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。 - `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。 - 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
- 跨表场景必须读取目标表结构link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。 - 跨表场景必须读取目标表结构link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
@@ -79,21 +79,21 @@ metadata:
## 查询与统计规则 ## 查询与统计规则
涉及查询、统计或判断结论时,先阅读 `references/lark-base-data-analysis-sop.md`,并遵守: 涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守:
1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。 1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉明细到本地上下文再手工筛选排序。 2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。 3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。 4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
6. 一次性分析优先用 `+data-query` 或临时视图;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
7. `+data-query` 返回聚合结果,不返回原始记录明细;需要输出实体字段时,用聚合结果中的业务 key 或 record_id 再走 record 路径回查。 7. `+data-query` 返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。
## 写入前置规则 ## 写入前置规则
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 `lark-base-field-json.md`;涉及 `formula` / `lookup` 时必须读对应 guide - 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。 - 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。 - 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。 - 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
@@ -105,7 +105,7 @@ metadata:
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 - `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token` - 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 - `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 临时视图适合一次性筛选/排序后读取;如果筛选结果对用户后续查看有价值,应保留为持久视图并说明名称和用途 - 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图
## Token 与链接 ## Token 与链接
@@ -125,9 +125,9 @@ metadata:
## Dashboard / Workflow / Role ## Dashboard / Workflow / Role
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 `dashboard-block-data-config.md`,组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get` - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 - Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
- Role 的复杂点是权限 JSON。角色操作先读入口 `lark-base-role-guide.md``+role-create` 只支持自定义角色;`+role-update` 是 delta merge角色 create/update 或解读完整配置时读权限 JSON SSOT `role-config.md``+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 - Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)`+role-create` 只支持自定义角色;`+role-update` 是 delta merge角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
## 常见恢复 ## 常见恢复
@@ -136,9 +136,9 @@ metadata:
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token | | `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token不要立刻改走裸 API | | `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token不要立刻改走裸 API |
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID注意空格、大小写和跨表字段 | | `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID注意空格、大小写和跨表字段 |
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 `lark-base-cell-value.md` 构造 CellValue | | `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 | | 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
| formula / lookup 创建失败 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 | | formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 | | `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
| `1254104` | 批量超过 200分批调用 | | `1254104` | 批量超过 200分批调用 |
| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 | | `1254291` | 并发写冲突,串行写入并在批次间短暂等待 |
@@ -146,15 +146,15 @@ metadata:
## 保留 Reference ## 保留 Reference
- `lark-base-data-analysis-sop.md`:查询/统计/全局结论的选路 SOP - [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
- `lark-base-data-query-guide.md` / `lark-base-data-query.md`:聚合查询入口 fewshot 与 DSL SSOT - [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
- `lark-base-cell-value.md`:记录 CellValue 构造 - [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
- `lark-base-field-json.md`:字段 JSON 构造 - [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
- `formula-field-guide.md` / `lookup-field-guide.md`:公式与 lookup 字段 - [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
- `lark-base-field-create.md` / `lark-base-field-update.md`:字段创建/更新命令级补充 - [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
- `lark-base-record-upsert.md` / `lark-base-record-batch-create.md` / `lark-base-record-batch-update.md` / `lark-base-record-history-list.md`:记录写入 JSON 与历史返回解释 - [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
- `lark-base-view-set-filter.md`:视图筛选 JSON - [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
- `lark-base-form-detail.md` / `lark-base-form-submit.md` / `lark-base-form-questions-create.md` / `lark-base-form-questions-update.md`:表单详情、提交和复杂 JSON - [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
- `lark-base-dashboard.md` / `dashboard-block-data-config.md` / `lark-base-dashboard-block-get-data.md`:仪表盘、组件配置与图表结果协议 - [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
- `lark-base-workflow-guide.md` / `lark-base-workflow-schema.md`workflow 入口与 steps JSON SSOT - [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)workflow 入口与 steps JSON SSOT
- `lark-base-role-guide.md` / `role-config.md`:角色入口与权限 JSON SSOT - [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT

View File

@@ -6,14 +6,15 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
- `+data-query`: entry guide [lark-base-data-query-guide.md](lark-base-data-query-guide.md), full DSL SSOT [lark-base-data-query.md](lark-base-data-query.md) - `+data-query`: entry guide [lark-base-data-query-guide.md](lark-base-data-query-guide.md), full DSL SSOT [lark-base-data-query.md](lark-base-data-query.md)
- 视图筛选: [lark-base-view-set-filter.md](lark-base-view-set-filter.md) - 视图筛选: [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
- 视图排序/投影、记录读取: 先 get/list 现状,确认字段 ID、字段名、分页和投影范围 - 记录读取: `+record-list` / `+record-search` / `+record-get`,先确认字段 ID、字段名、分页和投影范围
## 0. Hard Rules ## 0. Hard Rules
- 全局问题不能用默认 `+record-list --limit N` 片面地回答。 - 全局问题不能用默认 `+record-list --limit N` 片面地回答。
- `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。 - `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。
- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义必须在 Base 云端查询服务中完成筛选、排序或聚合。 - “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义必须在 Base 云端查询服务中完成筛选、排序或聚合。
- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name不要用它替代金额、状态、日期、空值等结构化条件 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`
- `+record-search` 用于关键词检索字段的展示文本;金额、状态、日期、空值、关联等结构化条件继续用 `--filter-json` 表达。
- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。 - 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。
- 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键不能替代最终输出除非用户明确要求输出这些键值。 - 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键不能替代最终输出除非用户明确要求输出这些键值。
- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。 - 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。
@@ -22,39 +23,160 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
| 用户意图 | 首选路径 | 关键规则 | | 用户意图 | 首选路径 | 关键规则 |
| --- | --- | --- | | --- | --- | --- |
| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 | | 看几条、预览、示例 | `+record-list --limit N --field-id ...` | 保持局部语义;不要推广为全局结论 |
| 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 | | 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 |
| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 | | 明确关键词 | `+record-search --keyword ... --search-field ... --field-id ...` | 必须显式指定 `--search-field`;可叠加 `--filter-json` |
| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 | | 按条件找原始记录 | `+record-list --filter-json ...` | `filter-json` 与视图筛选结构一致,支持文本、数字、日期、选项、人员、群组、关联等值 |
| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 | | 排序 / TopN 原始记录 | `+record-list --filter-json ... --sort-json ... --limit N` | 最高/最新用 `desc:true`,最低/最早用 `desc:false`;数组顺序表达优先级;最多 10 个排序条件 |
| 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit | | 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit |
| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 | | 聚合后输出逐条记录 | `+data-query` 得到业务 key 或候选字段组合 -> `+record-list --filter-json` / `+record-get` 回查 | `+data-query` 维度行按字段组合去重且不返回 `record_id` |
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 | | 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段 |
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 | | 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询沉淀为持久视图 |
## 2. Execution Patterns ## 2. Execution Patterns
### 2.1 结构化明细与 TopN ### 2.1 结构化原始记录与 TopN
使用视图路径: 使用 `+record-list` 的 filter/sort 路径:
1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。 1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。
2. `+view-create` 创建 grid 视图 2. 筛选只用 `--filter-json``--filter-json @file`
3. 设置 filter/sort/visible fields 3. 排序用 `--sort-json`
4. `+record-list --view-id <view_id> --limit <N>` 读取结果 4. `--field-id` 做最小投影,`--limit` 控制返回数量
不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。筛选 JSON 见 view-set-filter reference排序和可见字段配置先读取现状再按目标字段、顺序和排序方向改写。 Example: string/number 条件 + TopN
### 2.2 聚合分析与 TopN ```bash
lark-cli base +record-list \
--base-token <base_token> \
--table-id <table_id> \
--filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"],["Score",">=",80]]}' \
--sort-json '[{"field":"Updated","desc":true}]' \
--field-id Name \
--field-id Title \
--field-id Score \
--limit 20
```
Example: 复杂筛选从文件读取:
```bash
lark-cli base +record-list \
--base-token <base_token> \
--table-id <table_id> \
--filter-json @filter.json \
--sort-json '[{"field":"Priority","desc":true}]' \
--field-id Name \
--field-id Tags \
--limit 50
```
`filter-json` 与视图筛选结构一致。下面只列常用 fewshot字段类型、operator、value 形状拿不准或需要人员、群组、关联、空值、地理位置、formula / lookup 等完整筛选时,先读 [lark-base-view-set-filter.md](lark-base-view-set-filter.md),再把同样的 filter JSON 传给 `--filter-json`
文本 `==`:字段值等于目标文本。
```json
{"logic":"and","conditions":[["Title","==","Launch plan"]]}
```
文本包含 / like文本字段包含目标片段operator 写 `intersects`
```json
{"logic":"and","conditions":[["Title","intersects","urgent"]]}
```
数字 `==`:字段值等于目标数字。
```json
{"logic":"and","conditions":[["Score","==",95]]}
```
日期 `==`字段值等于目标日期datetime / created_at / updated_at 用 `ExactDate(...)`
```json
{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}
```
选项 `==`:字段值匹配单个选项;选项值使用选项名数组,单个选项也写数组。
```json
{"logic":"and","conditions":[["Priority","==",["P0"]]]}
```
选项 `intersects`:字段值与给定选项集合有交集,常用于多选或“命中任一选项”。
```json
{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}
```
`--sort-json` 传排序数组,数组顺序就是优先级,`desc:true` 为降序,`desc:false` 为升序,最多 10 个排序条件。
### 2.2 关键词检索后叠加结构化条件
使用 `+record-search` 做关键词命中,结构化条件仍用 `--filter-json` 下推:
```bash
lark-cli base +record-search \
--base-token <base_token> \
--table-id <table_id> \
--keyword Alice \
--search-field Name \
--filter-json '{"logic":"and","conditions":[["Status","!=","Done"]]}' \
--sort-json '[{"field":"Updated","desc":true}]' \
--field-id Name \
--field-id Status \
--limit 20
```
不要把 `+record-search` 当成金额、状态、日期、空值、关联字段的结构化筛选入口;这些条件继续写成 `--filter-json`
### 2.3 聚合分析与 TopN
使用 `+data-query` 使用 `+data-query`
- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。 - 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。
- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。 - `pagination.limit` 是 Base 云端查询服务中的结果限制,不是本地分页扫描。
- 需要输出明细或用户可读字段时,先拿业务 key再用 record 路径精确回查。
- 常用聚合 fewshot 先读 [lark-base-data-query-guide.md](lark-base-data-query-guide.md);字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。 - 常用聚合 fewshot 先读 [lark-base-data-query-guide.md](lark-base-data-query-guide.md);字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。
- `+data-query` 可返回聚合结果或维度字段行;维度字段行按字段组合去重且不返回 `record_id`,不能当逐条原始记录结果使用。
- 需要输出逐条记录、记录定位或完整行级字段时,先用 `+data-query` 得到业务 key、分组值或候选字段组合再用 `+record-list --filter-json` / `+record-get` 回查。
### 2.3 关系查询与回查 Example: 分组计数:
```bash
lark-cli base +data-query \
--base-token <base_token> \
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"dimensions":[{"field_name":"Status","alias":"status"}],"measures":[{"field_name":"Status","aggregation":"count","alias":"count"}],"shaper":{"format":"flat"}}'
```
Example: 过滤后汇总并取 TopN
```bash
lark-cli base +data-query \
--base-token <base_token> \
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"filters":{"type":1,"conjunction":"and","conditions":[{"field_name":"Status","operator":"is","value":["Done"]}]},"sort":[{"field_name":"total_amount","order":"desc"}],"pagination":{"limit":10},"shaper":{"format":"flat"}}'
```
### 2.4 视图化与复用
一次性查询先用 `+record-list` / `+record-search` 的 filter/sort 验证。需要用户长期打开、共享或复用时,再把同一套 filter/sort 沉淀为视图。
Example: 将已验证的筛选排序写入视图:
```bash
lark-cli base +view-set-filter \
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json @filter.json
lark-cli base +view-set-sort \
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--json '{"sort_config":[{"field":"Priority","desc":true}]}'
```
手动配置和视图配置的优先级:
1. `--filter-json` 覆盖 `--view-id` 保存的 view filter JSON。
2. `--sort-json` 覆盖 `--view-id` 保存的 view sort config。
3. 没有手动 filter/sort 时,`--view-id` 使用视图自身保存的 filter/sort。
### 2.5 关系查询与回查
- link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。 - link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。
- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。 - 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。
@@ -71,17 +193,17 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
- `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。 - `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。
- `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size都表示可能还有未读取数据。 - `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size都表示可能还有未读取数据。
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。 - 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `+data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list` - 必须全量导出时,按 `+record-list` 分页语义串行翻页;不要并发调用 `+record-list`
## 4. Final Answer Check ## 4. Final Answer Check
形成交付输出前必须能确认: 形成交付输出前必须能确认:
- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。 - 问题范围是局部样例、单点定位、全局原始记录、聚合分析、多表关联,还是查询后写入。
- 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。 - 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。
- 如果使用 `jq` / shell本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。 - 如果使用 `jq` / shell本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。
- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。 - 如果使用 `+record-list` / `+record-search`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
- 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。 - 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。 - 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。

View File

@@ -14,7 +14,7 @@ Use `+data-query` when the user asks for server-side:
- sorted Top N or Bottom N - sorted Top N or Bottom N
- global statistical conclusions - global statistical conclusions
Do not use `+data-query` for raw record details. Use record commands for row-level output. `+data-query` can return dimension field rows, but those rows are grouped by dimension values and do not include `record_id`. Use `+record-list`, `+record-search`, or `+record-get` for row-level output, record identity, or full raw record details.
## Common Fewshots ## Common Fewshots

View File

@@ -54,7 +54,7 @@ lark-cli base +data-query \
"shaper": {"format": "flat"} "shaper": {"format": "flat"}
}' }'
# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key # 聚合或维度查询后如需读取逐条记录,先让 data-query 返回可回查的业务 key
lark-cli base +data-query \ lark-cli base +data-query \
--base-token MAGObxxxxx \ --base-token MAGObxxxxx \
--dsl '{ --dsl '{
@@ -419,16 +419,16 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 与记录读取组合 ## 与记录读取组合
`+data-query` 返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合: `+data-query` 返回聚合结果,也可在只传 `dimensions` 时返回维度字段行;这些维度行按字段组合去重,不包含 `record_id`,不能等同于逐条原始记录。需要输出聚合结果对应的原始记录字段、展示值、记录定位信息或关联表字段时,按以下方式组合:
1.`+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN得到业务 key、分组值或候选范围 1.`+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN得到业务 key、分组值或候选字段组合
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。 2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取逐条记录字段。
3. 如果拿到的是结构化业务 key例如编号、状态、日期、金额等优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。 3. 如果拿到的是结构化业务 key例如编号、状态、日期、金额等 `+record-list --filter-json` 做精确过滤后读取;不要用 `+record-search` 代替结构化条件。
4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。 4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。 5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。 6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。 不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量原始记录导出时回到 data analysis SOP 的 `+record-list` 分页规则。
## 坑点 ## 坑点
@@ -447,6 +447,6 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
- [lark-base](../SKILL.md) — 多维表格全部命令 - [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP - [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、`+record-list` / `+record-search` 回查和关系查询 SOP
- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范 - [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
- [lark-base-field-json.md](lark-base-field-json.md) — 字段类型与 JSON 结构 - [lark-base-field-json.md](lark-base-field-json.md) — 字段类型与 JSON 结构