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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user