diff --git a/Makefile b/Makefile index 26a35c4..bee928a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # dsfree2api — build / test helpers GO ?= go -VERSION ?= 0.6.4 +VERSION ?= 0.6.5 LDFLAGS := -s -w -X github.com/nyoungo/dsfree2api/internal/admin.Version=$(VERSION) BIN := bin/dsfree2api IMAGE ?= dsfree2api:latest diff --git a/internal/admin/server.go b/internal/admin/server.go index 23d0f20..c1473a5 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -34,7 +34,7 @@ const ( ) // Version is reported by the console and /api/overview. -var Version = "0.6.4" +var Version = "0.6.5" //go:embed web/* var webFS embed.FS diff --git a/internal/openai/continue.go b/internal/openai/continue.go index ceaeae9..84c5bc8 100644 --- a/internal/openai/continue.go +++ b/internal/openai/continue.go @@ -28,6 +28,9 @@ func ContinuationContext(partial string, head, tail int) string { // caps each generation (~1k tokens), so large tool-call JSON arrives in // pieces; the model must continue byte-for-byte instead of restarting. func ContinueInstruction(partial string, round, maxRounds int) string { + if preferDSMLContinue(partial) { + return dsmlContinueInstruction(partial, round, maxRounds) + } tail := partial if n := len(tail); n > 80 { tail = tail[n-80:] @@ -51,9 +54,36 @@ func ContinueInstruction(partial string, round, maxRounds int) string { deadline, utf8.RuneCountInString(partial), tail) } +// dsmlContinueInstruction 是 DSML 方言的续写指令:要求模型按原标记格式 +// 逐字符续写并补齐缺失的闭合标签。XML 内容里的换行是合法字符,因此不套用 +// JSON 分支的“换行必须写成 \n 转义”规则。 +func dsmlContinueInstruction(partial string, round, maxRounds int) string { + tail := partial + if n := len(tail); n > 80 { + tail = tail[n-80:] + } + deadline := fmt.Sprintf("这是第 %d/%d 次续写。", round, maxRounds) + if round >= maxRounds { + deadline += "这是最后一次续写机会:本轮必须先收束剩余内容(补齐未完成的标记),随后立即闭合全部标签并结束回复,禁止再展开任何新段落。" + } else { + deadline += "每轮只能输出约 2300 字符;若主体已完成或接近完成,请立即补齐缺失的闭合标签收尾,不要无限扩展。" + } + return fmt.Sprintf( + "上一条 assistant 回复因通道输出上限被截断(上面保留了它的开头与最新末尾,中间已省略),%s目前已输出约 %d 个字符,最后 80 个字符是:\n%s\n\n"+ + "请只输出【续写片段】:从上面末尾片段的最后一个字符之后开始逐字符接着写,直到整个工具调用标记完整闭合。"+ + "严格禁止:重复任何已输出内容、以 < 或 { 开头、重新生成整段工具调用结构、任何解释或前后缀。"+ + "参数内容写完后必须立即按原格式依次补齐缺失的闭合标签(parameter、invoke 及外层 calls),然后立刻结束回复;"+ + "参数内容中的换行、制表符按原样输出即可,无需转义。"+ + "禁止输出任何解释、Markdown 标记或后记文字。"+ + "注意合理收尾:内容已经很长,若主体已经完成就立即闭合标签结束,不要无限扩展新段落。"+ + "你的回复的第一个字符必须正好是被截断处的下一个字符。", + deadline, utf8.RuneCountInString(partial), tail) +} + // ToolCallTruncated reports whether text looks like a forced tool-call JSON // reply (see toolsPrompt) that the channel cut off before it could close. // Fenced ```json blocks are unwrapped first, mirroring TryParseToolCalls. +// Text without a JSON payload falls back to the DSML dialect check. func ToolCallTruncated(text string) bool { cand := strings.TrimSpace(text) if !strings.HasPrefix(cand, "{") { @@ -68,7 +98,8 @@ func ToolCallTruncated(text string) bool { // evaluate the payload itself, mirroring TryParseToolCalls. i := strings.Index(cand, `{"tool_calls"`) if i <= 0 { - return false + // 没有 JSON 载荷:再看是不是被截断的 DSML 方言标记。 + return dsmlTruncated(text) } cand = cand[i:] } @@ -274,7 +305,12 @@ func RunContinued( break } raw := seg.String() - piece := TrimOverlap(partial, EscapeControlsAfter(partial, raw)) + // DSML/XML 内容里的换行是合法字符,不能套用 JSON 字符串的转义规则。 + healed := raw + if !preferDSMLContinue(partial) { + healed = EscapeControlsAfter(partial, raw) + } + piece := TrimOverlap(partial, healed) if piece == "" { warn("tool-call continuation produced no new text", "round", round+1, "seg_chars", len(raw)) break diff --git a/internal/openai/dsml.go b/internal/openai/dsml.go new file mode 100644 index 0000000..df2dcec --- /dev/null +++ b/internal/openai/dsml.go @@ -0,0 +1,95 @@ +package openai + +import ( + "regexp" + "strings" +) + +// DeepSeek 模型偶尔会退回自己的 DSML 标记方言输出工具调用,形如: +// +// <||DSML|| calls> +// <||DSML|| invoke name="write"> +// <||DSML|| parameter name="filePath" string="true">a.html +// +// +// +// 分隔竖线可能全角(U+FF5C)也可能半角('|')、个数不固定(还有 +// <|DSML|> calls> 这种写法),且截断时闭标签整段缺失。解析策略是先把 +// DSML 标签改写成普通 XML 标签,再复用既有的 tagged 路径。 +// +// 组1=开/闭前缀(< 或 ' 不消费, +// 保证 invoke name="…" 的属性原样保留。 +var dsmlTagRe = regexp.MustCompile(`(?i)(?\s*([a-z_]\w*)`) + +// normalizeDSML 把 DSML 方言标签改写成普通 XML 标签:竖线标记整体去掉, +// 标签名统一小写,calls 归并为已知起点标签 tool_calls。返回 (文本, 是否 +// 确有标签被改写);不含 DSML 标记时原样返回,避免每轮解析都跑正则。 +func normalizeDSML(text string) (string, bool) { + if !strings.Contains(strings.ToLower(text), "dsml") { + return text, false + } + out := dsmlTagRe.ReplaceAllStringFunc(text, func(m string) string { + sub := dsmlTagRe.FindStringSubmatch(m) + if sub == nil { + return m + } + name := strings.ToLower(sub[2]) + if name == "calls" { + name = "tool_calls" + } + return sub[1] + name + }) + return out, out != text +} + +// ensureTaggedStart 给归一化后缺少已知起点标签的裸 invoke/function 块补一个 +// 起点:findStartTag 只认内置/自定义起点标签,DSML 若没写 +// calls 外层,不补起点就进不了 tagged 解析路径。 +func ensureTaggedStart(text string, opts ParseOptions) string { + lower := strings.ToLower(text) + for _, tag := range []string{"" + text[i:] + } + return text +} + +// dsmlTruncated 报告 DSML 方言的工具调用块是否被通道输出上限截断:开标签 +// 多于闭标签即视为未闭合。只认 DSML 方言本身,避免普通散文里的 XML 示例 +// 误触发续写轮次。 +func dsmlTruncated(text string) bool { + norm, ok := normalizeDSML(text) + if !ok { + return false + } + // 代码围栏里的标记是文档示例,解析层会跳过,同样不触发续写轮次。 + start := -1 + for _, tag := range []string{"", "= 0 && (start < 0 || i < start) { + start = i + } + } + if start >= 0 && isInsideCodeFence(norm, start) { + return false + } + if strings.Count(norm, " strings.Count(norm, "") { + return true + } + return strings.Count(norm, "") > strings.Count(norm, "") +} + +// preferDSMLContinue 报告续写片段应按 DSML/XML 方言组织而非 JSON:文本里 +// 有 DSML 标记且没有 JSON 工具载荷(有载荷时截断的是 JSON,仍走 JSON 指令)。 +func preferDSMLContinue(partial string) bool { + if strings.Contains(partial, `{"tool_calls"`) { + return false + } + _, ok := normalizeDSML(partial) + return ok +} diff --git a/internal/openai/dsml_test.go b/internal/openai/dsml_test.go new file mode 100644 index 0000000..4411471 --- /dev/null +++ b/internal/openai/dsml_test.go @@ -0,0 +1,240 @@ +package openai + +import ( + "context" + "strings" + "testing" +) + +// dsmlBlock 是用户原始样本的完整形态:双全角竖线标记、calls/invoke/ +// parameter 三层全部闭合。 +func dsmlBlock() string { + return `<||DSML|| calls><||DSML|| invoke name="write">` + + `<||DSML|| parameter name="filePath" string="true">tihu.html` + + `<||DSML|| parameter name="content" string="true">hi` + + `` +} + +func TestParseDSMLInvokeCalls(t *testing.T) { + remaining, calls, ok := TryParseToolCallsForTools(dsmlBlock(), toolsFor("write")) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if remaining != "" { + t.Fatalf("remaining = %q, want empty", remaining) + } + if calls[0].Function.Name != "write" { + t.Fatalf("name = %q", calls[0].Function.Name) + } + if got := argsField(t, calls[0], "filePath"); got != "tihu.html" { + t.Fatalf("filePath = %q", got) + } + if got := argsField(t, calls[0], "content"); got != "hi" { + t.Fatalf("content = %q", got) + } +} + +// 用户原始样本:截断场景 —— 最后一个 parameter 断在半截,没有任何闭标签。 +func TestParseDSMLTruncatedInvoke(t *testing.T) { + raw := `<||DSML|| calls><||DSML|| invoke name="write">` + + `<||DSML|| parameter name="filePath" string="true">tihu.html` + + `<||DSML|| parameter name="content" string="true">stopped mid` + _, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write")) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if calls[0].Function.Name != "write" { + t.Fatalf("name = %q", calls[0].Function.Name) + } + if got := argsField(t, calls[0], "filePath"); got != "tihu.html" { + t.Fatalf("filePath = %q", got) + } + want := `stopped mid` + if got := argsField(t, calls[0], "content"); got != want { + t.Fatalf("content = %q, want %q", got, want) + } +} + +// 半角单竖线写法(<|DSML|> calls>)同样要能解析。 +func TestParseDSMLPipeVariant(t *testing.T) { + raw := `<|DSML|> calls><|DSML|> invoke name="get_weather">` + + `<|DSML|> parameter name="city">北京 parameter>` + + ` invoke> calls>` + _, calls, ok := TryParseToolCalls(raw) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if calls[0].Function.Name != "get_weather" || argsField(t, calls[0], "city") != "北京" { + t.Fatalf("call = %+v", calls[0]) + } +} + +// 没写 calls 外层的裸 invoke 块:归一化后补 起点进 tagged 路径。 +func TestParseDSMLStandaloneInvoke(t *testing.T) { + raw := `<||DSML|| invoke name="read">` + + `<||DSML|| parameter name="path">/a.txt` + + `` + _, calls, ok := TryParseToolCalls(raw) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if calls[0].Function.Name != "read" || argsField(t, calls[0], "path") != "/a.txt" { + t.Fatalf("call = %+v", calls[0]) + } +} + +// 工具调用块两侧的散文要原样保留在 remaining 里。 +func TestParseDSMLKeepsProse(t *testing.T) { + raw := "好的,这就写。\n" + dsmlBlock() + "\n写完了。" + remaining, calls, ok := TryParseToolCalls(raw) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if !strings.Contains(remaining, "好的") || !strings.Contains(remaining, "写完了") { + t.Fatalf("remaining = %q", remaining) + } +} + +// 参数内容里形如 [{"name": …}] 的片段是被写入的文件数据,不能被 JSON +// 候选路径抢成假调用。 +func TestParseDSMLContentJSONNotHijacked(t *testing.T) { + raw := `<||DSML|| calls><||DSML|| invoke name="write">` + + `<||DSML|| parameter name="content" string="true">[{"name": "inner"}]` + + `` + _, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write")) + if !ok || len(calls) != 1 { + t.Fatalf("ok=%v calls=%+v", ok, calls) + } + if calls[0].Function.Name != "write" { + t.Fatalf("content 里的 JSON 抢走了调用名: %+v", calls[0]) + } + if !strings.Contains(calls[0].Function.Arguments, "inner") { + t.Fatalf("arguments = %q", calls[0].Function.Arguments) + } +} + +// 调用名不在已提供的工具清单里时,DSML 块按普通文本返回,不产出假调用。 +func TestParseDSMLRejectsUnknownTool(t *testing.T) { + raw := `<||DSML|| invoke name="not_a_tool">` + + `<||DSML|| parameter name="x">1` + if _, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write")); ok { + t.Fatalf("未知工具名被当成了真实调用: %+v", calls) + } +} + +// 代码围栏里的 DSML 示例是文档,不是调用。 +func TestParseDSMLInsideCodeFenceSkipped(t *testing.T) { + raw := "示例:\n```text\n" + dsmlBlock() + "\n```" + if _, calls, ok := TryParseToolCalls(raw); ok { + t.Fatalf("围栏示例被当成了调用: %+v", calls) + } +} + +func TestNormalizeDSML(t *testing.T) { + cases := []struct{ in, want string }{ + {`<||DSML|| calls>`, ``}, + {``, ``}, + {`<||DSML|| invoke name="write">`, ``}, + {"", ""}, + {`<|DSML|> invoke name="x">`, ``}, + {"", ""}, + {`<|DSML| parameter name="a">`, ``}, + {"没有 DSML 标记的普通文本", "没有 DSML 标记的普通文本"}, + } + for _, c := range cases { + got, changed := normalizeDSML(c.in) + if got != c.want { + t.Errorf("normalizeDSML(%q) = %q, want %q", c.in, got, c.want) + } + if wantChanged := c.in != c.want; changed != wantChanged { + t.Errorf("normalizeDSML(%q) changed = %v, want %v", c.in, changed, wantChanged) + } + } +} + +func TestToolCallTruncatedDSML(t *testing.T) { + open := `<||DSML|| calls><||DSML|| invoke name="write">` + + `<||DSML|| parameter name="content">abc` + closed := open + `` + fenced := "```text\n" + open + "\n```" + prose := "不要用 DSML 标记输出工具调用。" + cases := []struct { + in string + want bool + }{ + {open, true}, + {closed, false}, + {fenced, false}, + {prose, false}, + {"", false}, + } + for _, c := range cases { + if got := ToolCallTruncated(c.in); got != c.want { + t.Errorf("ToolCallTruncated(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestContinueInstructionDSML(t *testing.T) { + partial := strings.Repeat("x", 120) + `<||DSML|| invoke name="write">` + "TAIL80CHARS" + got := ContinueInstruction(partial, 2, 5) + for _, want := range []string{"2/5", "TAIL80CHARS", "闭合标签", "parameter"} { + if !strings.Contains(got, want) { + t.Errorf("DSML 续写指令缺少 %q:\n%s", want, got) + } + } + // JSON 分支的收尾 8 字符规则不能混进来。 + if strings.Contains(got, "右花括号") { + t.Errorf("DSML 续写指令混入了 JSON 收尾规则:\n%s", got) + } + // 含 JSON 载荷时仍走 JSON 指令。 + jsonPartial := strings.Repeat("x", 120) + `{"tool_calls": [{"trunc` + if j := ContinueInstruction(jsonPartial, 2, 5); !strings.Contains(j, "右花括号") { + t.Errorf("JSON 部分未走 JSON 指令:\n%s", j) + } +} + +// DSML 截断回复跨轮续写拼接:内容里的换行按 XML 语义原样保留, +// 不套用 JSON 字符串的 \n 转义。 +func TestRunContinuedStitchesDSML(t *testing.T) { + turns := []string{ + `<||DSML|| calls><||DSML|| invoke name="write">` + + `<||DSML|| parameter name="content" string="true">alpha "beta`, + " gamma\ndelta" + + ``, + } + prompts := 0 + var got strings.Builder + err := RunContinued(context.Background(), + "first-prompt", + func(string, int) string { return "continue-prompt" }, + 3, + func(_ context.Context, prompt string, emit func(string) error) error { + prompts++ + if prompts > len(turns) { + t.Fatalf("unexpected extra send #%d", prompts) + } + return emit(turns[prompts-1]) + }, + func(text string) error { got.WriteString(text); return nil }, + func(string, ...any) {}, + func(string, ...any) {}, + ) + if err != nil { + t.Fatalf("RunContinued: %v", err) + } + if prompts != 2 { + t.Fatalf("prompts = %d, want 2", prompts) + } + if ToolCallTruncated(got.String()) { + t.Fatal("stitched DSML reply still truncated") + } + _, calls, ok := TryParseToolCalls(got.String()) + if !ok || len(calls) != 1 { + t.Fatalf("TryParseToolCalls failed on stitched reply: ok=%v calls=%+v", ok, calls) + } + want := "alpha \"beta gamma\ndelta" + if content := argsField(t, calls[0], "content"); content != want { + t.Fatalf("content = %q, want %q", content, want) + } +} diff --git a/internal/openai/prompt.go b/internal/openai/prompt.go index 2366f7b..8f2ce7b 100644 --- a/internal/openai/prompt.go +++ b/internal/openai/prompt.go @@ -239,8 +239,9 @@ func toolsPrompt(tools []ToolDef, toolChoice json.RawMessage) string { `{"tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "tool_name", "arguments": "{\"param\": \"value\"}"}}]}`, "", // DeepSeek 模型有时会退回自己的 DSML 标记方言(<|DSML|> calls> / - // invoke / parameter),网关不解析该方言,整段会被当普通文本透传, - // 客户端收不到 tool_calls——指令层显式禁止,只认上面的 JSON。 + // invoke / parameter)。解析层已兼容该方言作为兜底(见 dsml.go), + // 但指令层仍显式禁止:JSON 是唯一保证完整送达的主路径,DSML 不走 + // 流式增量、只在收尾整体补发,让模型偏离主路径没有好处。 "Never call tools with DSML/XML-style markup (for example `<|DSML|> calls>` or " + "`<|DSML|> invoke name=\"...\">`). Those tags are discarded — only the " + "JSON format above is accepted.", diff --git a/internal/openai/prompt_test.go b/internal/openai/prompt_test.go index 257ce57..f98ca70 100644 --- a/internal/openai/prompt_test.go +++ b/internal/openai/prompt_test.go @@ -37,8 +37,8 @@ func TestBuildPromptWithTools(t *testing.T) { } // 红测试:上游模型曾用 DeepSeek 原生 DSML 标记(<|DSML|> calls> / invoke / -// parameter)代替 JSON 输出工具调用,网关三层(流式、解析、续写)都只认 -// JSON,整段标记被当普通文本发给客户端。指令层必须显式禁止该方言。 +// parameter)代替 JSON 输出工具调用。解析/续写层已兼容该方言兜底(dsml.go), +// 但 JSON 才是保证完整送达的主路径,指令层必须显式禁止该方言。 func TestToolsPromptForbidsDSML(t *testing.T) { prompt := BuildPrompt([]ChatMessage{ {Role: "user", Content: MessageContent{Text: "Hi"}}, diff --git a/internal/openai/toolcalls.go b/internal/openai/toolcalls.go index 89af561..586516d 100644 --- a/internal/openai/toolcalls.go +++ b/internal/openai/toolcalls.go @@ -45,7 +45,8 @@ func optionsForTools(tools []ToolDef) ParseOptions { // output. It mirrors the Python/Rust implementations: plain JSON, ```json // fences, generic ``` fences, XML// wrappers and the usual // model repairs (unquoted keys, invalid backslashes, field aliases, -// swapped/ spilled arguments) are all accepted. +// swapped/ spilled arguments) are all accepted, plus DeepSeek's DSML dialect +// (see dsml.go) including truncated blocks with missing closing tags. // // Returns the remaining prose plus the parsed tool calls (ok=false when the // text is ordinary chat). @@ -61,12 +62,35 @@ func TryParseToolCallsForTools(text string, tools []ToolDef) (string, []ToolCall // TryParseToolCallsWith is TryParseToolCalls with explicit options. func TryParseToolCallsWith(text string, opts ParseOptions) (string, []ToolCall, bool) { + norm, isDSML := normalizeDSML(text) + // DSML 方言优先按 tagged 路径解析:参数内容里形如 [{"name":…}] 的片段是 + // 正被写入的文件数据,若先跑 JSON 候选路径会被抢成假调用;解析失败时 + // 仍回落到下面的常规两段路径。 + if isDSML { + if remaining, calls, ok := parseTaggedToolCalls(ensureTaggedStart(norm, opts), opts); ok && matchesOfferedTools(calls, opts) { + return remaining, calls, true + } + } if remaining, calls, ok := parseJSONToolCalls(text, opts); ok { return remaining, calls, true } return parseTaggedToolCalls(text, opts) } +// matchesOfferedTools 报告解析结果与工具清单是否相容:DSML 只是兜底方言, +// 调用名不在已提供的工具里时多半是散文引用了标记格式,按普通文本返回。 +func matchesOfferedTools(calls []ToolCall, opts ParseOptions) bool { + if len(opts.ToolNames) == 0 { + return true + } + for _, c := range calls { + if !containsString(opts.ToolNames, c.Function.Name) { + return false + } + } + return len(calls) > 0 +} + // --------------------------------------------------------------------------- // JSON payload path // --------------------------------------------------------------------------- @@ -459,6 +483,14 @@ func parseTaggedToolCalls(text string, opts ParseOptions) (string, []ToolCall, b // or object first, then the XML dialects. func extractTaggedCalls(inner string, opts ParseOptions) []ToolCall { fallbackName := extractXMLName(inner) + // 以 0 { + return calls + } + } if calls := parseJSONInText(inner, opts, fallbackName); len(calls) > 0 { return calls } @@ -511,10 +543,13 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall { head := inner[abs : abs+gt+1] bodyStart := abs + gt + 1 ci := strings.Index(lower[bodyStart:], "") - if ci < 0 { - return calls + // 截断兜底:闭标签缺失时把剩余文本整体当作 body,最后一个 + // parameter 的值由 parseXMLParams 取到末尾。 + bodyEnd := len(inner) + if ci >= 0 { + bodyEnd = bodyStart + ci } - body := inner[abs : bodyStart+ci] + body := inner[abs:bodyEnd] name := xmlAttr(head, "name") if name == "" { name = xmlTagValue(body, "name") @@ -522,6 +557,9 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall { if name != "" { calls = append(calls, makeXMLCall(name, parseXMLParams(body))) } + if ci < 0 { + return calls + } pos = bodyStart + ci + len("") } } @@ -592,6 +630,12 @@ func parseXMLParams(body string) map[string]any { break } abs := pos + i + // `') if gt < 0 { break @@ -604,13 +648,21 @@ func parseXMLParams(body string) map[string]any { } name := xmlAttr(head, "name") ci := strings.Index(lower[bodyStart:], closeTag) - if ci < 0 { - break + // 截断兜底:闭标签缺失时,值取到下一个同名开标签或 body 末尾。 + end := len(body) + if ci >= 0 { + end = bodyStart + ci + } else if j := strings.Index(lower[bodyStart:], open); j >= 0 { + end = bodyStart + j } if name != "" { - params[name] = coerceXMLValue(body[bodyStart : bodyStart+ci]) + params[name] = coerceXMLValue(body[bodyStart:end]) } - pos = bodyStart + ci + len(closeTag) + if ci < 0 { + pos = end + continue + } + pos = end + len(closeTag) } } if len(params) == 0 { @@ -626,6 +678,10 @@ func parseXMLParams(body string) map[string]any { return params } +func isTagNameChar(c byte) bool { + return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + func coerceXMLValue(s string) any { t := strings.TrimSpace(s) if t == "" {