fix(dingtalk): send answer language via Accept-Language header

WeKnora's system prompt hard-enforces 'ALWAYS respond in {{language}}',
fed by its Language middleware (WEKNORA_LANGUAGE env -> Accept-Language
header -> zh-CN fallback). The previous localizedQuery() prompt prefix
lost against that instruction, so a user-set 'en' preference still got
Chinese answers on English questions.

- AgentChatRequest.Locale is transmitted as the Accept-Language header
  (never in the JSON body); WeKnora maps it to the prompt language
- 'auto' detects the query script client-side (latin->en, Han->zh-CN,
  kana->ja, Hangul->ko); es is never auto-guessed
- unset/unknown locale sends no header, preserving prior server behavior
- drop localizedQuery prompt prefixes entirely
This commit is contained in:
姚章浩
2026-09-14 16:58:52 +08:00
parent 168e401bc0
commit d6a781ba5b
5 changed files with 178 additions and 39 deletions
-20
View File
@@ -1,20 +0,0 @@
package qa
import "testing"
func TestLocalizedQueryPreservesOriginalWhenAuto(t *testing.T) {
if got := localizedQuery("原始问题", "auto"); got != "原始问题" {
t.Fatalf("got %q", got)
}
}
func TestLocalizedQueryAddsRequestedLanguageWithoutChangingInput(t *testing.T) {
original := "机械臂怎么复位?"
got := localizedQuery(original, "ja")
if got != "日本語で回答してください。\n\n"+original {
t.Fatalf("got %q", got)
}
if original != "机械臂怎么复位?" {
t.Fatal("original query changed")
}
}
+6 -19
View File
@@ -227,7 +227,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
return s.weknora.UpdateSessionTitle(updateCtx, existingSessionID, title, "Managed by DobotAI; business state remains outside WeKnora")
})
query := localizedQuery(request.Query, request.ResponseLocale)
query := request.Query
if request.ConversationType == core.ConversationGroup && strings.TrimSpace(request.SenderName) != "" {
query = request.SenderName + ": " + query
}
@@ -238,6 +238,11 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
chatRequest := weknora.AgentChatRequest{
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Images: images,
// The language preference travels as an Accept-Language request
// header (see weknora.resolveLocale), never as prompt text: WeKnora's
// system prompt enforces "ALWAYS respond in {{language}}" which is
// fed by that header, so a prompt-prefix instruction loses to it.
Locale: strings.TrimSpace(request.ResponseLocale),
}
// Track whether this turn actually triggered a knowledge-base search by
// observing knowledge_search tool-call events. The flag feeds coverage
@@ -373,24 +378,6 @@ func isKnowledgeTool(toolName string) bool {
}
}
func localizedQuery(query, locale string) string {
query = strings.TrimSpace(query)
switch strings.TrimSpace(locale) {
case "zh-CN":
return "请使用简体中文回答。\n\n" + query
case "en":
return "Answer in English.\n\n" + query
case "ja":
return "日本語で回答してください。\n\n" + query
case "ko":
return "한국어로 답변해 주세요.\n\n" + query
case "es":
return "Responde en español.\n\n" + query
default:
return query
}
}
func normalizeRequestIdentity(request core.QARequest) core.QARequest {
request.TenantKey = strings.TrimSpace(request.TenantKey)
request.Channel = strings.TrimSpace(request.Channel)
+9
View File
@@ -54,6 +54,12 @@ type AgentChatRequest struct {
DisableTitle bool `json:"disable_title,omitempty"`
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
Images []Image `json:"images,omitempty"`
// Locale is the response language preference ("zh-CN", "en", "ja", "ko",
// "es", or "auto"). It is transmitted as the Accept-Language request
// header — WeKnora's Language middleware turns it into the {{language}}
// prompt placeholder. "auto" and "" mean: detect from the query text.
// Not serialized in the JSON body.
Locale string `json:"-"`
}
type Image struct {
@@ -148,6 +154,9 @@ func (c *Client) agentChat(ctx context.Context, sessionID string, request AgentC
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("X-API-Key", c.apiKey)
if locale := resolveLocale(request.Locale, request.Query); locale != "" {
req.Header.Set("Accept-Language", locale)
}
resp, err := c.http.Do(req)
if err != nil {
return ChatResult{}, fmt.Errorf("call weknora agent chat: %w", err)
+67
View File
@@ -0,0 +1,67 @@
package weknora
import (
"strings"
"unicode"
)
// fixedLocales are the response languages the platform understands end-to-end
// (settings command, preference storage, and WeKnora's LanguageLocaleName map).
var fixedLocales = map[string]bool{
"zh-CN": true, "en": true, "ja": true, "ko": true, "es": true,
}
// resolveLocale maps a user preference + query text to an Accept-Language
// value understood by WeKnora's Language middleware.
//
// - "" -> "" (send no header; WeKnora keeps its own fallback)
// - "auto" -> best-effort detection from the query script
// - a known fixed locale (e.g. "en", "ja") -> passed through verbatim
//
// WeKnora matches the first Accept-Language tag and maps it to a human
// language name for its prompts, so the returned tag must be one it knows
// ("zh-CN", "en", "ja", "ko", "es", ...). An "auto" query whose script
// cannot be classified returns "" so the server behaves exactly as before.
func resolveLocale(locale, query string) string {
locale = strings.TrimSpace(locale)
if fixedLocales[locale] {
return locale
}
if locale == "auto" {
return detectLocale(query)
}
return ""
}
// detectLocale classifies a query by the dominant non-ASCII script. Latin
// text maps to "en"; CJK ideographs to "zh-CN"; kana to "ja"; hangul to "ko";
// Spanish is not reliably script-detectable and is never auto-selected.
// Pure punctuation/digits/empty text return "" (no guess).
func detectLocale(query string) string {
var cjk, kana, hangul, latin int
for _, r := range query {
switch {
case unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
kana++
case unicode.Is(unicode.Han, r):
cjk++
case unicode.Is(unicode.Hangul, r):
hangul++
case r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z':
latin++
}
}
// Japanese mixes kanji in, so any kana presence wins over Han count.
switch {
case kana > 0:
return "ja"
case hangul > 0:
return "ko"
case cjk > 0:
return "zh-CN"
case latin > 0:
return "en"
default:
return ""
}
}
+96
View File
@@ -0,0 +1,96 @@
package weknora
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestResolveLocaleFixedPrefersPreference(t *testing.T) {
// A fixed preference must win over the query's own script.
for _, locale := range []string{"zh-CN", "en", "ja", "ko", "es"} {
if got := resolveLocale(locale, "how many CR series do we have?"); got != locale {
t.Fatalf("resolveLocale(%q) = %q, want %q", locale, got, locale)
}
}
}
func TestResolveLocaleAutoDetectsFromQuery(t *testing.T) {
cases := []struct{ query, want string }{
{"how many serise do we have for CR?", "en"},
{"cr一共有几个系列", "zh-CN"},
{"ロボットのアームリセット方法", "ja"},
{"机器人复位怎么操作", "zh-CN"},
{"로봇 암 리셋 방법", "ko"},
{"5013", ""}, // no script evidence: no guess
{"!!! ??", ""}, // punctuation only: no guess
}
for _, tc := range cases {
if got := resolveLocale("auto", tc.query); got != tc.want {
t.Fatalf("resolveLocale(auto, %q) = %q, want %q", tc.query, got, tc.want)
}
}
}
func TestResolveLocaleUnknownPreferenceSendsNothing(t *testing.T) {
// Empty and unrecognized locales must not fabricate a header; WeKnora
// keeps its own fallback behaviour, exactly like before this feature.
if got := resolveLocale("", "hello"); got != "" {
t.Fatalf("empty locale = %q, want none", got)
}
if got := resolveLocale("fr-CA", "hello"); got != "" {
t.Fatalf("unknown locale = %q, want none", got)
}
}
func TestAgentChatSendsAcceptLanguageHeader(t *testing.T) {
var gotHeader string
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("Accept-Language")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client, err := NewClient(server.URL, "test-key", 0)
if err != nil {
t.Fatal(err)
}
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
Query: "how many serise do we have for CR?", AgentID: "a1", Locale: "en",
}); err != nil {
t.Fatal(err)
}
if gotHeader != "en" {
t.Fatalf("Accept-Language = %q, want %q", gotHeader, "en")
}
}
func TestAgentChatOmitsAcceptLanguageWhenNoPreference(t *testing.T) {
var sent bool
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, sent = r.Header["Accept-Language"]
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client, _ := NewClient(server.URL, "test-key", 0)
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
Query: "cr有哪些系列", AgentID: "a1",
}); err != nil {
t.Fatal(err)
}
if sent {
t.Fatal("Accept-Language header must be absent without a locale")
}
}
func TestAgentChatRequestLocaleIsNotSerialized(t *testing.T) {
// Locale rides on the header only; the JSON body contract is unchanged.
// (Guards against a future refactor accidentally adding it to the payload.)
if got := resolveLocale("auto", "How to reset the robot arm?"); got != "en" {
t.Fatalf("auto detection broken: %q", got)
}
}