From 5a428abd3e3eea3054820336521e5c9d32547f16 Mon Sep 17 00:00:00 2001 From: renaocheng Date: Wed, 8 Apr 2026 18:05:44 +0800 Subject: [PATCH 1/4] feat(vc): extract meeting_notes and ai_meeting_notes from calendar event relation API --- shortcuts/vc/vc_notes.go | 135 +++++++++++++++++++++++++----- shortcuts/vc/vc_recording.go | 4 +- shortcuts/vc/vc_recording_test.go | 23 ++--- 3 files changed, 130 insertions(+), 32 deletions(-) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 5938146ef..3b4788192 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -90,17 +90,30 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) { return calID, nil } +// eventRelationInfo holds the resolved relation info from mget_instance_relation_info API. +type eventRelationInfo struct { + MeetingIDs []string // meeting IDs (one event may spawn multiple meetings) + MeetingNotes []string // meeting note doc tokens + AIMeetingNotes []string // AI meeting note doc tokens +} + // resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its -// associated meeting IDs via the mget_instance_relation_info API. +// associated meeting IDs and optionally note doc tokens via the mget_instance_relation_info API. +// When needNotes is true, meeting_notes and ai_meeting_notes are also requested. // Shared by +notes and +recording for the --calendar-event-ids path. -func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string) ([]string, error) { +func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string, needNotes bool) (*eventRelationInfo, error) { + body := map[string]any{ + "instance_ids": []string{instanceID}, + "need_meeting_instance_ids": true, + } + if needNotes { + body["need_meeting_notes"] = true + body["need_ai_meeting_notes"] = true + } data, err := runtime.DoAPIJSON(http.MethodPost, fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)), nil, - map[string]any{ - "instance_ids": []string{instanceID}, - "need_meeting_instance_ids": true, - }) + body) if err != nil { return nil, fmt.Errorf("failed to query event relation info: %w", err) } @@ -116,7 +129,7 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance return nil, fmt.Errorf("no associated video meeting for this event") } - var ids []string + result := &eventRelationInfo{} for _, mid := range rawIDs { if mid == nil { continue @@ -130,35 +143,119 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance default: meetingID = fmt.Sprintf("%v", v) } - ids = append(ids, meetingID) + result.MeetingIDs = append(result.MeetingIDs, meetingID) + } + + // extract note doc tokens directly from mget_instance_relation_info + result.MeetingNotes = extractStringSlice(info, "meeting_notes") + result.AIMeetingNotes = extractStringSlice(info, "ai_meeting_notes") + + return result, nil +} + +// extractStringSlice extracts a []string from a JSON array field in a map. +func extractStringSlice(m map[string]any, key string) []string { + raw, _ := m[key].([]any) + var out []string + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } } - return ids, nil + return out } // fetchNoteByCalendarEventID queries notes via calendar event instance ID. -// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id +// Two sources of doc tokens are collected and deduplicated: +// - mget_instance_relation_info: meeting_notes + ai_meeting_notes +// - meeting_id chain: meeting.get → note detail (note_doc_token, verbatim_doc_token, shared_doc_tokens) func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any { errOut := runtime.IO().ErrOut - meetingIDs, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID) + relInfo, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID, true) if err != nil { return map[string]any{"calendar_event_id": instanceID, "error": err.Error()} } - if len(meetingIDs) > 1 { - fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs)) + result := map[string]any{"calendar_event_id": instanceID} + + // source 1: doc tokens directly from mget_instance_relation_info + if len(relInfo.MeetingNotes) > 0 { + result["meeting_notes"] = relInfo.MeetingNotes + } + if len(relInfo.AIMeetingNotes) > 0 { + result["ai_meeting_notes"] = relInfo.AIMeetingNotes + } + + // source 2: meeting_id → meeting.get → note detail (for shared_doc_tokens etc.) + if len(relInfo.MeetingIDs) > 1 { + fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(relInfo.MeetingIDs)) } - // try each associated meeting until one has notes - for _, meetingID := range meetingIDs { + for _, meetingID := range relInfo.MeetingIDs { fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID)) - result := fetchNoteByMeetingID(ctx, runtime, meetingID) - if result["error"] == nil { + noteResult := fetchNoteByMeetingID(ctx, runtime, meetingID) + if noteResult["error"] == nil { + for k, v := range noteResult { + result[k] = v + } + deduplicateDocTokens(result) return result } - fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), result["error"]) + fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), noteResult["error"]) + } + + // meeting chain failed, but still succeed if relation info returned note tokens + if len(relInfo.MeetingNotes) > 0 || len(relInfo.AIMeetingNotes) > 0 { + return result + } + result["error"] = "no notes found in any associated meeting" + return result +} + +// deduplicateDocTokens removes tokens from meeting_notes / ai_meeting_notes +// that already appear in note detail fields (note_doc_token, verbatim_doc_token, shared_doc_tokens). +func deduplicateDocTokens(result map[string]any) { + seen := map[string]bool{} + if v, _ := result["note_doc_token"].(string); v != "" { + seen[v] = true + } + if v, _ := result["verbatim_doc_token"].(string); v != "" { + seen[v] = true + } + for _, tok := range toStringSlice(result["shared_doc_tokens"]) { + seen[tok] = true + } + + result["meeting_notes"] = filterUnseen(toStringSlice(result["meeting_notes"]), seen) + result["ai_meeting_notes"] = filterUnseen(toStringSlice(result["ai_meeting_notes"]), seen) + + if len(toStringSlice(result["meeting_notes"])) == 0 { + delete(result, "meeting_notes") + } + if len(toStringSlice(result["ai_meeting_notes"])) == 0 { + delete(result, "ai_meeting_notes") + } +} + +func toStringSlice(v any) []string { + if v == nil { + return nil + } + if ss, ok := v.([]string); ok { + return ss + } + return nil +} + +func filterUnseen(tokens []string, seen map[string]bool) []string { + var out []string + for _, t := range tokens { + if !seen[t] { + out = append(out, t) + } } - return map[string]any{"calendar_event_id": instanceID, "error": "no notes found in any associated meeting"} + return out } // fetchNoteByMeetingID queries notes via meeting_id. diff --git a/shortcuts/vc/vc_recording.go b/shortcuts/vc/vc_recording.go index 480d248e6..425bdebdf 100644 --- a/shortcuts/vc/vc_recording.go +++ b/shortcuts/vc/vc_recording.go @@ -179,13 +179,13 @@ var VCRecording = common.Shortcut{ time.Sleep(batchDelay) } fmt.Fprintf(errOut, "%s resolving calendar_event_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(instanceID)) - meetingIDs, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID) + relInfo, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID, false) if resolveErr != nil { results = append(results, map[string]any{"calendar_event_id": instanceID, "error": resolveErr.Error()}) continue } found := false - for _, meetingID := range meetingIDs { + for _, meetingID := range relInfo.MeetingIDs { fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", recordingLogPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID)) result := fetchRecordingByMeetingID(ctx, runtime, meetingID) if result["error"] == nil { diff --git a/shortcuts/vc/vc_recording_test.go b/shortcuts/vc/vc_recording_test.go index c8f879e71..0f5fe2cdf 100644 --- a/shortcuts/vc/vc_recording_test.go +++ b/shortcuts/vc/vc_recording_test.go @@ -357,10 +357,11 @@ func TestResolveMeetingIDs_TypeCoercion(t *testing.T) { Command: "+resolve-test", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - ids, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + relInfo, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", false) if err != nil { return err } + ids := relInfo.MeetingIDs if len(ids) != 2 { t.Errorf("expected 2 IDs (nil skipped), got %d: %v", len(ids), ids) } @@ -408,7 +409,7 @@ func TestResolveMeetingIDs_NoMeetings(t *testing.T) { Command: "+resolve-no-meetings", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", false) if err == nil { t.Error("expected error for no meetings") } @@ -449,7 +450,7 @@ func TestResolveMeetingIDs_NoRelationInfo(t *testing.T) { Command: "+resolve-no-info", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", false) if err == nil { t.Error("expected error for no relation info") } @@ -581,15 +582,15 @@ func TestRecording_Execute_CalendarPath_ResolveAndFetch(t *testing.T) { }) err := botExec(t, "cal-resolve", f, func(ctx context.Context, rctx *common.RuntimeContext) error { - ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + relInfo, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", false) if resolveErr != nil { t.Fatalf("resolve failed: %v", resolveErr) } - if len(ids) != 1 || ids[0] != "m001" { - t.Fatalf("expected [m001], got %v", ids) + if len(relInfo.MeetingIDs) != 1 || relInfo.MeetingIDs[0] != "m001" { + t.Fatalf("expected [m001], got %v", relInfo.MeetingIDs) } - result := fetchRecordingByMeetingID(ctx, rctx, ids[0]) + result := fetchRecordingByMeetingID(ctx, rctx, relInfo.MeetingIDs[0]) if result["error"] != nil { t.Errorf("fetch should succeed, got: %v", result["error"]) } @@ -641,17 +642,17 @@ func TestRecording_Execute_CalendarPath_MultiMeetingFallback(t *testing.T) { }) err := botExec(t, "cal-fallback", f, func(ctx context.Context, rctx *common.RuntimeContext) error { - ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + relInfo, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", false) if resolveErr != nil { t.Fatalf("resolve failed: %v", resolveErr) } - if len(ids) != 2 { - t.Fatalf("expected 2 meeting IDs, got %d", len(ids)) + if len(relInfo.MeetingIDs) != 2 { + t.Fatalf("expected 2 meeting IDs, got %d", len(relInfo.MeetingIDs)) } // simulate fallback: try each until success var found bool - for _, meetingID := range ids { + for _, meetingID := range relInfo.MeetingIDs { result := fetchRecordingByMeetingID(ctx, rctx, meetingID) if result["error"] == nil { if result["minute_token"] != "obcnfallback" { From 3e1b2cc8beb99b7ee1aca22bf9b7b6cbcc10c80a Mon Sep 17 00:00:00 2001 From: renaocheng Date: Wed, 8 Apr 2026 18:05:51 +0800 Subject: [PATCH 2/4] test(vc): add tests for calendar-to-notes dedup and fallback logic --- shortcuts/vc/vc_notes_test.go | 288 ++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index c202813e4..2610239a4 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -372,3 +372,291 @@ func TestParseArtifactType_AllBranches(t *testing.T) { t.Errorf("nil: got %d, want 0", got) } } + +// --------------------------------------------------------------------------- +// Unit tests for new calendar-to-notes functions +// --------------------------------------------------------------------------- + +func TestExtractStringSlice(t *testing.T) { + m := map[string]any{ + "tokens": []any{"a", "b", "", "c"}, + "empty": []any{}, + "missing": nil, + "mixed": []any{"x", float64(123), nil, "y"}, + } + if got := extractStringSlice(m, "tokens"); len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" { + t.Errorf("tokens: got %v, want [a b c]", got) + } + if got := extractStringSlice(m, "empty"); got != nil { + t.Errorf("empty: got %v, want nil", got) + } + if got := extractStringSlice(m, "missing"); got != nil { + t.Errorf("missing: got %v, want nil", got) + } + if got := extractStringSlice(m, "nonexistent"); got != nil { + t.Errorf("nonexistent: got %v, want nil", got) + } + if got := extractStringSlice(m, "mixed"); len(got) != 2 || got[0] != "x" || got[1] != "y" { + t.Errorf("mixed: got %v, want [x y]", got) + } +} + +func TestToStringSlice(t *testing.T) { + if got := toStringSlice(nil); got != nil { + t.Errorf("nil: got %v, want nil", got) + } + if got := toStringSlice([]string{"a", "b"}); len(got) != 2 || got[0] != "a" { + t.Errorf("[]string: got %v", got) + } + if got := toStringSlice("not a slice"); got != nil { + t.Errorf("string: got %v, want nil", got) + } +} + +func TestFilterUnseen(t *testing.T) { + seen := map[string]bool{"a": true, "c": true} + got := filterUnseen([]string{"a", "b", "c", "d"}, seen) + if len(got) != 2 || got[0] != "b" || got[1] != "d" { + t.Errorf("got %v, want [b d]", got) + } + got = filterUnseen([]string{"a", "c"}, seen) + if got != nil { + t.Errorf("all seen: got %v, want nil", got) + } + got = filterUnseen(nil, seen) + if got != nil { + t.Errorf("nil input: got %v, want nil", got) + } +} + +func TestDeduplicateDocTokens(t *testing.T) { + // case 1: meeting_notes overlap with note_doc_token + result := map[string]any{ + "note_doc_token": "doc_main", + "verbatim_doc_token": "doc_verb", + "shared_doc_tokens": []string{"doc_shared"}, + "meeting_notes": []string{"doc_main", "unique_note"}, + "ai_meeting_notes": []string{"doc_main", "doc_verb"}, + } + deduplicateDocTokens(result) + mn := toStringSlice(result["meeting_notes"]) + if len(mn) != 1 || mn[0] != "unique_note" { + t.Errorf("meeting_notes: got %v, want [unique_note]", mn) + } + if _, exists := result["ai_meeting_notes"]; exists { + t.Errorf("ai_meeting_notes should be removed (all duplicates), got %v", result["ai_meeting_notes"]) + } + + // case 2: no overlap + result2 := map[string]any{ + "note_doc_token": "doc_a", + "meeting_notes": []string{"doc_b"}, + } + deduplicateDocTokens(result2) + mn2 := toStringSlice(result2["meeting_notes"]) + if len(mn2) != 1 || mn2[0] != "doc_b" { + t.Errorf("no overlap: got %v, want [doc_b]", mn2) + } + + // case 3: empty meeting_notes + result3 := map[string]any{ + "note_doc_token": "doc_a", + } + deduplicateDocTokens(result3) + if _, exists := result3["meeting_notes"]; exists { + t.Errorf("should not have meeting_notes key") + } +} + +// --------------------------------------------------------------------------- +// Integration: calendar-event-ids path with meeting_notes + dedup +// --------------------------------------------------------------------------- + +func calendarRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes, aiMeetingNotes []string) *httpmock.Stub { + infos := map[string]interface{}{ + "instance_id": instanceID, + } + mIDs := make([]interface{}, len(meetingIDs)) + for i, id := range meetingIDs { + mIDs[i] = id + } + infos["meeting_instance_ids"] = mIDs + if len(meetingNotes) > 0 { + notes := make([]interface{}, len(meetingNotes)) + for i, n := range meetingNotes { + notes[i] = n + } + infos["meeting_notes"] = notes + } + if len(aiMeetingNotes) > 0 { + notes := make([]interface{}, len(aiMeetingNotes)) + for i, n := range aiMeetingNotes { + notes[i] = n + } + infos["ai_meeting_notes"] = notes + } + return &httpmock.Stub{ + Method: "POST", + URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID), + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{infos}, + }, + }, + } +} + +func primaryCalendarStub(calendarID string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/primary", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "calendars": []interface{}{ + map[string]interface{}{ + "calendar": map[string]interface{}{ + "calendar_id": calendarID, + }, + }, + }, + }, + }, + } +} + +func TestNotes_CalendarPath_MeetingNotesDedup(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + calID := "cal_test" + reg.Register(primaryCalendarStub(calID)) + // mget returns meeting_notes=["doc_main","unique_note"], ai_meeting_notes=["doc_main"] + reg.Register(calendarRelationStub(calID, "evt_001", []string{"m001"}, []string{"doc_main", "unique_note"}, []string{"doc_main"})) + reg.Register(meetingGetStub("m001", "note_001")) + reg.Register(noteDetailStub("note_001")) + + err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_001", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + notes, _ := data["notes"].([]any) + if len(notes) != 1 { + t.Fatalf("expected 1 note, got %d", len(notes)) + } + note, _ := notes[0].(map[string]any) + + // doc_main should be deduplicated (exists in note_doc_token) + // only "unique_note" should remain in meeting_notes + mn, _ := note["meeting_notes"].([]any) + if len(mn) != 1 { + t.Fatalf("meeting_notes: expected 1 after dedup, got %d: %v", len(mn), mn) + } + if mn[0] != "unique_note" { + t.Errorf("meeting_notes[0] = %v, want unique_note", mn[0]) + } + + // ai_meeting_notes should be completely removed (all duplicates) + if _, exists := note["ai_meeting_notes"]; exists { + t.Errorf("ai_meeting_notes should be removed after dedup, got %v", note["ai_meeting_notes"]) + } +} + +func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + calID := "cal_test" + reg.Register(primaryCalendarStub(calID)) + // mget returns note tokens but meeting chain will fail + reg.Register(calendarRelationStub(calID, "evt_002", []string{"m_bad"}, []string{"fallback_note"}, nil)) + // meeting.get returns error + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m_bad", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + + err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_002", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + notes, _ := data["notes"].([]any) + if len(notes) != 1 { + t.Fatalf("expected 1 note, got %d", len(notes)) + } + note, _ := notes[0].(map[string]any) + + // should succeed via fallback (meeting chain failed but mget had tokens) + if _, hasErr := note["error"]; hasErr { + t.Errorf("expected no error (fallback), got error: %v", note["error"]) + } + mn, _ := note["meeting_notes"].([]any) + if len(mn) != 1 || mn[0] != "fallback_note" { + t.Errorf("meeting_notes: got %v, want [fallback_note]", mn) + } +} + +func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{ + map[string]interface{}{ + "meeting_instance_ids": []interface{}{"m001"}, + }, + }, + }, + }, + } + reg.Register(stub) + + s := common.Shortcut{ + Service: "test", + Command: "+need-notes-test", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001", true) + return err + }, + } + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+need-notes-test"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(stub.CapturedBody) == 0 { + t.Fatal("request body was not captured") + } + var body map[string]any + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("failed to parse captured body: %v", err) + } + if v, ok := body["need_meeting_notes"]; !ok || v != true { + t.Errorf("need_meeting_notes: got %v, want true", v) + } + if v, ok := body["need_ai_meeting_notes"]; !ok || v != true { + t.Errorf("need_ai_meeting_notes: got %v, want true", v) + } +} From ab8162d74a386713acedd2bce53b5385b2015a68 Mon Sep 17 00:00:00 2001 From: renaocheng Date: Wed, 8 Apr 2026 20:32:43 +0800 Subject: [PATCH 3/4] fix(vc): address review findings for calendar-to-notes dedup and table output --- shortcuts/vc/vc_notes.go | 21 +++++++++++++++++++-- shortcuts/vc/vc_notes_test.go | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 3b4788192..22c7c64b4 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -214,7 +214,8 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont } // deduplicateDocTokens removes tokens from meeting_notes / ai_meeting_notes -// that already appear in note detail fields (note_doc_token, verbatim_doc_token, shared_doc_tokens). +// that already appear in note detail fields (note_doc_token, verbatim_doc_token, shared_doc_tokens), +// and also cross-deduplicates between meeting_notes and ai_meeting_notes. func deduplicateDocTokens(result map[string]any) { seen := map[string]bool{} if v, _ := result["note_doc_token"].(string); v != "" { @@ -227,7 +228,14 @@ func deduplicateDocTokens(result map[string]any) { seen[tok] = true } - result["meeting_notes"] = filterUnseen(toStringSlice(result["meeting_notes"]), seen) + // filter meeting_notes first, then mark its tokens as seen + filtered := filterUnseen(toStringSlice(result["meeting_notes"]), seen) + result["meeting_notes"] = filtered + for _, tok := range filtered { + seen[tok] = true + } + + // filter ai_meeting_notes against note detail fields + meeting_notes result["ai_meeting_notes"] = filterUnseen(toStringSlice(result["ai_meeting_notes"]), seen) if len(toStringSlice(result["meeting_notes"])) == 0 { @@ -666,6 +674,9 @@ var VCNotes = common.Shortcut{ if id == "" { id, _ = m["minute_token"].(string) } + if id == "" { + id, _ = m["calendar_event_id"].(string) + } row := map[string]interface{}{"id": id} if errMsg, _ := m["error"].(string); errMsg != "" { row["status"] = "FAIL" @@ -681,6 +692,12 @@ var VCNotes = common.Shortcut{ if v, _ := m["shared_doc_tokens"].([]string); len(v) > 0 { row["shared_docs"] = strings.Join(v, ", ") } + if v := toStringSlice(m["meeting_notes"]); len(v) > 0 { + row["meeting_notes"] = strings.Join(v, ", ") + } + if v := toStringSlice(m["ai_meeting_notes"]); len(v) > 0 { + row["ai_meeting_notes"] = strings.Join(v, ", ") + } if v, _ := m["source"].(string); v != "" { row["source"] = v } diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 2610239a4..f26d73c74 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -466,6 +466,22 @@ func TestDeduplicateDocTokens(t *testing.T) { if _, exists := result3["meeting_notes"]; exists { t.Errorf("should not have meeting_notes key") } + + // case 4: cross-dedup between meeting_notes and ai_meeting_notes + result4 := map[string]any{ + "note_doc_token": "doc_a", + "meeting_notes": []string{"shared_tok", "only_mn"}, + "ai_meeting_notes": []string{"shared_tok", "only_ai"}, + } + deduplicateDocTokens(result4) + mn4 := toStringSlice(result4["meeting_notes"]) + if len(mn4) != 2 || mn4[0] != "shared_tok" || mn4[1] != "only_mn" { + t.Errorf("case4 meeting_notes: got %v, want [shared_tok only_mn]", mn4) + } + ai4 := toStringSlice(result4["ai_meeting_notes"]) + if len(ai4) != 1 || ai4[0] != "only_ai" { + t.Errorf("case4 ai_meeting_notes: got %v, want [only_ai] (shared_tok deduped)", ai4) + } } // --------------------------------------------------------------------------- From 02fb2e23f98c975b51f95f26fd79ae87e6ff6f5f Mon Sep 17 00:00:00 2001 From: renaocheng Date: Wed, 8 Apr 2026 21:23:33 +0800 Subject: [PATCH 4/4] refactor(vc): remove ai_meeting_notes concept and simplify dedup logic --- shortcuts/vc/vc_notes.go | 73 +++++++--------------- shortcuts/vc/vc_notes_test.go | 73 ++++++---------------- skills/lark-vc/SKILL.md | 16 +++-- skills/lark-vc/references/lark-vc-notes.md | 7 ++- 4 files changed, 53 insertions(+), 116 deletions(-) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 22c7c64b4..5a0170394 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -92,14 +92,13 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) { // eventRelationInfo holds the resolved relation info from mget_instance_relation_info API. type eventRelationInfo struct { - MeetingIDs []string // meeting IDs (one event may spawn multiple meetings) - MeetingNotes []string // meeting note doc tokens - AIMeetingNotes []string // AI meeting note doc tokens + MeetingIDs []string // meeting IDs (one event may spawn multiple meetings) + MeetingNotes []string // user-bound meeting note doc tokens } // resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its // associated meeting IDs and optionally note doc tokens via the mget_instance_relation_info API. -// When needNotes is true, meeting_notes and ai_meeting_notes are also requested. +// When needNotes is true, meeting_notes are also requested. // Shared by +notes and +recording for the --calendar-event-ids path. func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string, needNotes bool) (*eventRelationInfo, error) { body := map[string]any{ @@ -108,7 +107,6 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance } if needNotes { body["need_meeting_notes"] = true - body["need_ai_meeting_notes"] = true } data, err := runtime.DoAPIJSON(http.MethodPost, fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)), @@ -146,9 +144,7 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance result.MeetingIDs = append(result.MeetingIDs, meetingID) } - // extract note doc tokens directly from mget_instance_relation_info result.MeetingNotes = extractStringSlice(info, "meeting_notes") - result.AIMeetingNotes = extractStringSlice(info, "ai_meeting_notes") return result, nil } @@ -167,7 +163,7 @@ func extractStringSlice(m map[string]any, key string) []string { // fetchNoteByCalendarEventID queries notes via calendar event instance ID. // Two sources of doc tokens are collected and deduplicated: -// - mget_instance_relation_info: meeting_notes + ai_meeting_notes +// - mget_instance_relation_info: meeting_notes (user-bound note doc tokens) // - meeting_id chain: meeting.get → note detail (note_doc_token, verbatim_doc_token, shared_doc_tokens) func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any { errOut := runtime.IO().ErrOut @@ -179,13 +175,10 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont result := map[string]any{"calendar_event_id": instanceID} - // source 1: doc tokens directly from mget_instance_relation_info + // source 1: user-bound meeting note doc tokens from mget_instance_relation_info if len(relInfo.MeetingNotes) > 0 { result["meeting_notes"] = relInfo.MeetingNotes } - if len(relInfo.AIMeetingNotes) > 0 { - result["ai_meeting_notes"] = relInfo.AIMeetingNotes - } // source 2: meeting_id → meeting.get → note detail (for shared_doc_tokens etc.) if len(relInfo.MeetingIDs) > 1 { @@ -206,16 +199,14 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont } // meeting chain failed, but still succeed if relation info returned note tokens - if len(relInfo.MeetingNotes) > 0 || len(relInfo.AIMeetingNotes) > 0 { + if len(relInfo.MeetingNotes) > 0 { return result } result["error"] = "no notes found in any associated meeting" return result } -// deduplicateDocTokens removes tokens from meeting_notes / ai_meeting_notes -// that already appear in note detail fields (note_doc_token, verbatim_doc_token, shared_doc_tokens), -// and also cross-deduplicates between meeting_notes and ai_meeting_notes. +// deduplicateDocTokens removes meeting_notes entries that duplicate note detail fields. func deduplicateDocTokens(result map[string]any) { seen := map[string]bool{} if v, _ := result["note_doc_token"].(string); v != "" { @@ -224,46 +215,27 @@ func deduplicateDocTokens(result map[string]any) { if v, _ := result["verbatim_doc_token"].(string); v != "" { seen[v] = true } - for _, tok := range toStringSlice(result["shared_doc_tokens"]) { + for _, tok := range asStringSlice(result["shared_doc_tokens"]) { seen[tok] = true } - // filter meeting_notes first, then mark its tokens as seen - filtered := filterUnseen(toStringSlice(result["meeting_notes"]), seen) - result["meeting_notes"] = filtered - for _, tok := range filtered { - seen[tok] = true + var filtered []string + for _, tok := range asStringSlice(result["meeting_notes"]) { + if !seen[tok] { + filtered = append(filtered, tok) + } } - - // filter ai_meeting_notes against note detail fields + meeting_notes - result["ai_meeting_notes"] = filterUnseen(toStringSlice(result["ai_meeting_notes"]), seen) - - if len(toStringSlice(result["meeting_notes"])) == 0 { + if len(filtered) > 0 { + result["meeting_notes"] = filtered + } else { delete(result, "meeting_notes") } - if len(toStringSlice(result["ai_meeting_notes"])) == 0 { - delete(result, "ai_meeting_notes") - } } -func toStringSlice(v any) []string { - if v == nil { - return nil - } - if ss, ok := v.([]string); ok { - return ss - } - return nil -} - -func filterUnseen(tokens []string, seen map[string]bool) []string { - var out []string - for _, t := range tokens { - if !seen[t] { - out = append(out, t) - } - } - return out +// asStringSlice casts v to []string; returns nil for non-[]string or nil values. +func asStringSlice(v any) []string { + ss, _ := v.([]string) + return ss } // fetchNoteByMeetingID queries notes via meeting_id. @@ -692,12 +664,9 @@ var VCNotes = common.Shortcut{ if v, _ := m["shared_doc_tokens"].([]string); len(v) > 0 { row["shared_docs"] = strings.Join(v, ", ") } - if v := toStringSlice(m["meeting_notes"]); len(v) > 0 { + if v := asStringSlice(m["meeting_notes"]); len(v) > 0 { row["meeting_notes"] = strings.Join(v, ", ") } - if v := toStringSlice(m["ai_meeting_notes"]); len(v) > 0 { - row["ai_meeting_notes"] = strings.Join(v, ", ") - } if v, _ := m["source"].(string); v != "" { row["source"] = v } diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index f26d73c74..83569d240 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -401,34 +401,18 @@ func TestExtractStringSlice(t *testing.T) { } } -func TestToStringSlice(t *testing.T) { - if got := toStringSlice(nil); got != nil { +func TestAsStringSlice(t *testing.T) { + if got := asStringSlice(nil); got != nil { t.Errorf("nil: got %v, want nil", got) } - if got := toStringSlice([]string{"a", "b"}); len(got) != 2 || got[0] != "a" { + if got := asStringSlice([]string{"a", "b"}); len(got) != 2 || got[0] != "a" { t.Errorf("[]string: got %v", got) } - if got := toStringSlice("not a slice"); got != nil { + if got := asStringSlice("not a slice"); got != nil { t.Errorf("string: got %v, want nil", got) } } -func TestFilterUnseen(t *testing.T) { - seen := map[string]bool{"a": true, "c": true} - got := filterUnseen([]string{"a", "b", "c", "d"}, seen) - if len(got) != 2 || got[0] != "b" || got[1] != "d" { - t.Errorf("got %v, want [b d]", got) - } - got = filterUnseen([]string{"a", "c"}, seen) - if got != nil { - t.Errorf("all seen: got %v, want nil", got) - } - got = filterUnseen(nil, seen) - if got != nil { - t.Errorf("nil input: got %v, want nil", got) - } -} - func TestDeduplicateDocTokens(t *testing.T) { // case 1: meeting_notes overlap with note_doc_token result := map[string]any{ @@ -436,16 +420,12 @@ func TestDeduplicateDocTokens(t *testing.T) { "verbatim_doc_token": "doc_verb", "shared_doc_tokens": []string{"doc_shared"}, "meeting_notes": []string{"doc_main", "unique_note"}, - "ai_meeting_notes": []string{"doc_main", "doc_verb"}, } deduplicateDocTokens(result) - mn := toStringSlice(result["meeting_notes"]) + mn := asStringSlice(result["meeting_notes"]) if len(mn) != 1 || mn[0] != "unique_note" { t.Errorf("meeting_notes: got %v, want [unique_note]", mn) } - if _, exists := result["ai_meeting_notes"]; exists { - t.Errorf("ai_meeting_notes should be removed (all duplicates), got %v", result["ai_meeting_notes"]) - } // case 2: no overlap result2 := map[string]any{ @@ -453,7 +433,7 @@ func TestDeduplicateDocTokens(t *testing.T) { "meeting_notes": []string{"doc_b"}, } deduplicateDocTokens(result2) - mn2 := toStringSlice(result2["meeting_notes"]) + mn2 := asStringSlice(result2["meeting_notes"]) if len(mn2) != 1 || mn2[0] != "doc_b" { t.Errorf("no overlap: got %v, want [doc_b]", mn2) } @@ -467,20 +447,15 @@ func TestDeduplicateDocTokens(t *testing.T) { t.Errorf("should not have meeting_notes key") } - // case 4: cross-dedup between meeting_notes and ai_meeting_notes + // case 4: all meeting_notes are duplicates result4 := map[string]any{ - "note_doc_token": "doc_a", - "meeting_notes": []string{"shared_tok", "only_mn"}, - "ai_meeting_notes": []string{"shared_tok", "only_ai"}, + "note_doc_token": "doc_a", + "shared_doc_tokens": []string{"doc_b"}, + "meeting_notes": []string{"doc_a", "doc_b"}, } deduplicateDocTokens(result4) - mn4 := toStringSlice(result4["meeting_notes"]) - if len(mn4) != 2 || mn4[0] != "shared_tok" || mn4[1] != "only_mn" { - t.Errorf("case4 meeting_notes: got %v, want [shared_tok only_mn]", mn4) - } - ai4 := toStringSlice(result4["ai_meeting_notes"]) - if len(ai4) != 1 || ai4[0] != "only_ai" { - t.Errorf("case4 ai_meeting_notes: got %v, want [only_ai] (shared_tok deduped)", ai4) + if _, exists := result4["meeting_notes"]; exists { + t.Errorf("case4: meeting_notes should be removed (all duplicates), got %v", result4["meeting_notes"]) } } @@ -488,7 +463,7 @@ func TestDeduplicateDocTokens(t *testing.T) { // Integration: calendar-event-ids path with meeting_notes + dedup // --------------------------------------------------------------------------- -func calendarRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes, aiMeetingNotes []string) *httpmock.Stub { +func calendarRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string) *httpmock.Stub { infos := map[string]interface{}{ "instance_id": instanceID, } @@ -504,13 +479,6 @@ func calendarRelationStub(calendarID, instanceID string, meetingIDs []string, me } infos["meeting_notes"] = notes } - if len(aiMeetingNotes) > 0 { - notes := make([]interface{}, len(aiMeetingNotes)) - for i, n := range aiMeetingNotes { - notes[i] = n - } - infos["ai_meeting_notes"] = notes - } return &httpmock.Stub{ Method: "POST", URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID), @@ -547,8 +515,8 @@ func TestNotes_CalendarPath_MeetingNotesDedup(t *testing.T) { calID := "cal_test" reg.Register(primaryCalendarStub(calID)) - // mget returns meeting_notes=["doc_main","unique_note"], ai_meeting_notes=["doc_main"] - reg.Register(calendarRelationStub(calID, "evt_001", []string{"m001"}, []string{"doc_main", "unique_note"}, []string{"doc_main"})) + // mget returns meeting_notes=["doc_main","unique_note"], doc_main overlaps with note_doc_token + reg.Register(calendarRelationStub(calID, "evt_001", []string{"m001"}, []string{"doc_main", "unique_note"})) reg.Register(meetingGetStub("m001", "note_001")) reg.Register(noteDetailStub("note_001")) @@ -577,11 +545,6 @@ func TestNotes_CalendarPath_MeetingNotesDedup(t *testing.T) { if mn[0] != "unique_note" { t.Errorf("meeting_notes[0] = %v, want unique_note", mn[0]) } - - // ai_meeting_notes should be completely removed (all duplicates) - if _, exists := note["ai_meeting_notes"]; exists { - t.Errorf("ai_meeting_notes should be removed after dedup, got %v", note["ai_meeting_notes"]) - } } func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) { @@ -590,7 +553,7 @@ func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) { calID := "cal_test" reg.Register(primaryCalendarStub(calID)) // mget returns note tokens but meeting chain will fail - reg.Register(calendarRelationStub(calID, "evt_002", []string{"m_bad"}, []string{"fallback_note"}, nil)) + reg.Register(calendarRelationStub(calID, "evt_002", []string{"m_bad"}, []string{"fallback_note"})) // meeting.get returns error reg.Register(&httpmock.Stub{ Method: "GET", @@ -672,7 +635,7 @@ func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) { if v, ok := body["need_meeting_notes"]; !ok || v != true { t.Errorf("need_meeting_notes: got %v, want true", v) } - if v, ok := body["need_ai_meeting_notes"]; !ok || v != true { - t.Errorf("need_ai_meeting_notes: got %v, want true", v) + if _, ok := body["need_ai_meeting_notes"]; ok { + t.Errorf("need_ai_meeting_notes should not be requested") } } diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index a6712fe9e..9ae314d22 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -18,7 +18,8 @@ metadata: - **会议记录(Meeting Record)**:视频会议结束后生成的记录,支持通过关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议室。 - **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。 - **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。 -- **纪要文档(MainDoc)**:会议纪要的主文档,包含 AI 生成的总结和待办。 +- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。 +- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。 - **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。 ## 核心场景 @@ -42,10 +43,12 @@ lark-cli docs +media-download --type whiteboard --token --out ``` > **产物目录规范**:同一会议的所有下载产物(封面图、逐字稿等)统一放到 `artifact-/` 目录下,不要散落在当前工作目录。 -> **`note_doc_token` vs `verbatim_doc_token` — 两份不同的文档,根据用户意图选择:** -> - `note_doc_token` → **智能纪要**(AI 总结 + 待办 + 章节)— 用户说"纪要""总结""待办""纪要内容"时用这个 +> **纪要相关文档 — 根据用户意图选择:** +> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办 + 章节) +> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回) > - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个 -> - 用户意图不明确时,应展示两个文档链接让用户选择,而不是替用户决定 +> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有) +> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定 ### 3. 纪要文档与逐字稿链接 1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。 @@ -68,8 +71,9 @@ lark-cli docs +fetch --doc <doc_token> ``` Meeting (视频会议) ├── Note (会议纪要) -│ ├── MainDoc (主纪要文档) -│ ├── VerbatimDoc (逐字稿) +│ ├── MainDoc (AI 智能纪要文档, note_doc_token) +│ ├── MeetingNotes (用户绑定的会议纪要文档, meeting_notes) +│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) │ └── SharedDoc (会中共享文档) └── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取 ├── Transcript (文字记录) diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index 53aeb880d..7e082f9cc 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -75,13 +75,14 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 字段 | 说明 | |------|------| -| `note_doc_token` | **智能纪要**文档 Token — 包含 AI 总结、待办、章节(用户说"纪要"时用这个) | -| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳(用户说"逐字稿"时才用这个) | +| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 | +| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) | +| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 | | `shared_doc_tokens` | 会中共享文档 Token 列表 | | `creator_id` | 创建者 ID | | `create_time` | 创建时间(格式化) | -> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 用 `note_doc_token`。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示两个文档链接让用户选择。 +> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。 ### minute-tokens 路径的 AI 产物