feat(dingtalk): per-frame wide-table grouping (stream keeps tables flowing while streaming)
This commit is contained in:
+16
-12
@@ -6,18 +6,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cardWideTableGroupingDisabled reports whether the DingTalk final-frame
|
||||
// normalizer must stop rewriting over-wide answer tables into grouped lists.
|
||||
// Disabling it keeps real tables in the card, which is what the operator wants
|
||||
// now that the PC client renders wide-screen cards; clients that still cannot
|
||||
// lay out wide tables need the grouping, so the default stays enabled.
|
||||
// DINGTALK_CARD_WIDE_TABLE_GROUP=off disables it.
|
||||
func cardWideTableGroupingDisabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP"))) {
|
||||
case "off", "0", "false", "no":
|
||||
log.Printf("DingTalk wide-table grouping disabled by configuration")
|
||||
return true
|
||||
// cardWideTableGroupingMode returns the configured handling of over-wide answer
|
||||
// tables (see dingtalk.tableGroupingForMode):
|
||||
//
|
||||
// unset / on → grouped list on every frame (historical behaviour)
|
||||
// off → real tables on every frame
|
||||
// stream → grouped list while streaming, real table on the final frame
|
||||
//
|
||||
// The default stays "on" so an environment without the variable renders exactly
|
||||
// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes.
|
||||
func cardWideTableGroupingMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP")))
|
||||
switch mode {
|
||||
case "off", "0", "false", "no", "stream", "streaming", "partial":
|
||||
log.Printf("DingTalk wide-table grouping mode=%s", mode)
|
||||
return mode
|
||||
default:
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -323,8 +323,8 @@ func main() {
|
||||
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
|
||||
LanguageResolver: settingsRuntime.repository,
|
||||
|
||||
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
|
||||
WideTableGroupingDisabled: cardWideTableGroupingDisabled(),
|
||||
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
|
||||
WideTableGrouping: cardWideTableGroupingMode(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -215,18 +215,22 @@ func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardWideTableGroupingDisabledToggle(t *testing.T) {
|
||||
func TestCardWideTableGroupingMode(t *testing.T) {
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
|
||||
if cardWideTableGroupingDisabled() {
|
||||
t.Fatal("wide-table grouping must default to enabled")
|
||||
if got := cardWideTableGroupingMode(); got != "" {
|
||||
t.Fatalf("unset must keep the historical mode, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
|
||||
if !cardWideTableGroupingDisabled() {
|
||||
t.Fatal("DINGTALK_CARD_WIDE_TABLE_GROUP=off must disable wide-table grouping")
|
||||
if got := cardWideTableGroupingMode(); got != "off" {
|
||||
t.Fatalf("off must be passed through, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "ON")
|
||||
if cardWideTableGroupingDisabled() {
|
||||
t.Fatal("any value other than off must keep grouping enabled")
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ")
|
||||
if got := cardWideTableGroupingMode(); got != "stream" {
|
||||
t.Fatalf("stream must be normalised, got %q", got)
|
||||
}
|
||||
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on")
|
||||
if got := cardWideTableGroupingMode(); got != "" {
|
||||
t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,10 +168,11 @@ type HandlerOptions struct {
|
||||
// delivery timeout, otherwise the remaining images are dropped. Zero keeps
|
||||
// finalDeliveryTimeout.
|
||||
OutboundRewriteTimeout time.Duration
|
||||
// WideTableGroupingDisabled stops the final-frame normalizer from rewriting
|
||||
// over-wide tables into grouped lists. Leave false on clients whose cards
|
||||
// cannot lay out wide tables.
|
||||
WideTableGroupingDisabled bool
|
||||
// WideTableGrouping selects how over-wide answer tables are adapted, per
|
||||
// frame: "" or "on" groups on every frame (historical), "off" never groups,
|
||||
// "stream" groups only while the answer is still streaming and keeps the
|
||||
// real table on the final frame. See tableGroupingForMode.
|
||||
WideTableGrouping string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
@@ -193,10 +194,12 @@ type Handler struct {
|
||||
// outboundRewriteTimeout applies to the image rewrite stage only; zero
|
||||
// falls back to finalDeliveryTimeout.
|
||||
outboundRewriteTimeout time.Duration
|
||||
// wideTableGroupingDisabled keeps the final frame's tables as tables
|
||||
// instead of rewriting over-wide ones into grouped lists. Used while the
|
||||
// client renders wide tables itself (PC wide-screen cards).
|
||||
wideTableGroupingDisabled bool
|
||||
// groupTablesPartial / groupTablesFinal decide per frame whether an
|
||||
// over-wide table becomes a grouped list. Grouped lists survive the
|
||||
// streaming frames where a table is still incomplete; the final frame can
|
||||
// keep the real table, which the PC wide-screen card lays out itself.
|
||||
groupTablesPartial bool
|
||||
groupTablesFinal bool
|
||||
}
|
||||
|
||||
// NewHandler accepts at most one image downloader. A nil/omitted downloader
|
||||
@@ -228,6 +231,8 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
|
||||
if reply == nil {
|
||||
return nil, errors.New("dingtalk handler: markdown sender is required")
|
||||
}
|
||||
groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
qa: qa,
|
||||
@@ -245,8 +250,9 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
|
||||
cardUpdateInterval: 500 * time.Millisecond,
|
||||
finalDeliveryTimeout: defaultHTTPTimeout,
|
||||
|
||||
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
|
||||
wideTableGroupingDisabled: opts.WideTableGroupingDisabled,
|
||||
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
|
||||
groupTablesPartial: groupPartial,
|
||||
groupTablesFinal: groupFinal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -669,8 +675,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
}
|
||||
|
||||
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
|
||||
options := defaultTableNormalizeOptions()
|
||||
options.groupWideTables = !h.wideTableGroupingDisabled
|
||||
options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal}
|
||||
if h.outboundRewriter == nil {
|
||||
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
|
||||
}
|
||||
@@ -691,7 +696,9 @@ func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) s
|
||||
if ok {
|
||||
content = sanitizer.SanitizePartial(content)
|
||||
}
|
||||
return h.rewriteCardImages(ctx, stableMarkdownPrefix(sanitizeDisplayAnswer(content)), false)
|
||||
options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial}
|
||||
content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
|
||||
return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false)
|
||||
}
|
||||
|
||||
// rewriteCardImages converts image references in card content into DingTalk
|
||||
|
||||
@@ -23,6 +23,24 @@ func defaultTableNormalizeOptions() tableNormalizeOptions {
|
||||
return tableNormalizeOptions{groupWideTables: true}
|
||||
}
|
||||
|
||||
// tableGroupingForMode maps the configured mode to the two frames the card
|
||||
// renders. An unknown or empty mode keeps the historical behaviour (group on
|
||||
// every frame), so an unset environment never changes rendering.
|
||||
//
|
||||
// ""/"on" group on every frame
|
||||
// "off" never group: real tables everywhere
|
||||
// "stream" group while streaming, real table on the final frame
|
||||
func tableGroupingForMode(mode string) (partial, final bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "off", "0", "false", "no":
|
||||
return false, false
|
||||
case "stream", "streaming", "partial":
|
||||
return true, false
|
||||
default:
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
|
||||
// cards without changing the knowledge itself. It (1) replaces <br> inside
|
||||
// table cells with a full-width separator, and (2) rewrites any table that
|
||||
|
||||
@@ -81,19 +81,45 @@ func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteOutboundContentWideTableGroupingToggle proves the handler wires
|
||||
// the switch through to the final frame, which is what the client sees.
|
||||
func TestRewriteOutboundContentWideTableGroupingToggle(t *testing.T) {
|
||||
func TestTableGroupingForMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
wantPartial bool
|
||||
wantFinal bool
|
||||
}{
|
||||
{mode: "", wantPartial: true, wantFinal: true},
|
||||
{mode: "on", wantPartial: true, wantFinal: true},
|
||||
{mode: "weird", wantPartial: true, wantFinal: true},
|
||||
{mode: "off", wantPartial: false, wantFinal: false},
|
||||
{mode: "NO", wantPartial: false, wantFinal: false},
|
||||
{mode: "stream", wantPartial: true, wantFinal: false},
|
||||
{mode: " Stream ", wantPartial: true, wantFinal: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
partial, final := tableGroupingForMode(tc.mode)
|
||||
if partial != tc.wantPartial || final != tc.wantFinal {
|
||||
t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWideTableGroupingPerFrame proves the switch reaches both frames the card
|
||||
// renders: the streaming frames and the final one. The streaming path drops
|
||||
// unstable table blocks, so with grouping off a partial frame simply carries no
|
||||
// table at all.
|
||||
func TestWideTableGroupingPerFrame(t *testing.T) {
|
||||
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
|
||||
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
|
||||
const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:"
|
||||
cases := []struct {
|
||||
name string
|
||||
disabled bool
|
||||
wantTable bool
|
||||
wantDetail string
|
||||
name string
|
||||
mode string
|
||||
partialWant [2]bool // [grouped, table]
|
||||
finalWant [2]bool // [grouped, table]
|
||||
}{
|
||||
{name: "grouping enabled rewrites wide table", disabled: false, wantTable: false, wantDetail: "**关节最大速度**"},
|
||||
{name: "grouping disabled keeps wide table", disabled: true, wantTable: true, wantDetail: "| 对比项 |"},
|
||||
{name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}},
|
||||
{name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}},
|
||||
{name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -101,17 +127,25 @@ func TestRewriteOutboundContentWideTableGroupingToggle(t *testing.T) {
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
&fakeQA{},
|
||||
&fakeReply{},
|
||||
HandlerOptions{WideTableGroupingDisabled: tc.disabled},
|
||||
HandlerOptions{WideTableGrouping: tc.mode},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
got := h.rewriteOutboundContent(context.Background(), wide)
|
||||
if strings.Contains(got, "| 对比项 |") != tc.wantTable {
|
||||
t.Fatalf("table present = %v, want %v, got %q", strings.Contains(got, "| 对比项 |"), tc.wantTable, got)
|
||||
frames := map[string]struct {
|
||||
got string
|
||||
want [2]bool
|
||||
}{
|
||||
"partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant},
|
||||
"final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant},
|
||||
}
|
||||
if !strings.Contains(got, tc.wantDetail) {
|
||||
t.Fatalf("missing %q in %q", tc.wantDetail, got)
|
||||
for name, frame := range frames {
|
||||
grouped := strings.Contains(frame.got, groupMarker)
|
||||
table := strings.Contains(frame.got, tableMarker)
|
||||
if grouped != frame.want[0] || table != frame.want[1] {
|
||||
t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q",
|
||||
name, grouped, table, frame.want[0], frame.want[1], frame.got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user