Merge pull request 'feat: default smart reasoning and robust channel delivery' (#16) from feat/default-reasoning-mode into main

This commit was merged in pull request #16.
This commit is contained in:
2026-09-15 17:13:32 +08:00
22 changed files with 781 additions and 186 deletions
+12
View File
@@ -79,10 +79,22 @@ func main() {
if err != nil {
log.Fatal(err)
}
defaultMode := reasoningmode.ModeQuick
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
switch raw {
case "reasoning":
defaultMode = reasoningmode.ModeReasoning
case "quick":
defaultMode = reasoningmode.ModeQuick
default:
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
}
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
reasoningmode.WithDefaultMode(defaultMode),
)
if err != nil {
log.Fatal(err)
@@ -0,0 +1,2 @@
ALTER TABLE reasoning_mode_states
DROP COLUMN user_selected;
@@ -0,0 +1,6 @@
-- 000022_reasoning_default_mode: support an operator-configurable default
-- mode for the /think reasoning router. The new column records whether a
-- user has explicitly chosen a mode (/think on|off); users who never have
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
ALTER TABLE reasoning_mode_states
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,34 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE REASONING_MODE_STATES",
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown022DropsUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
}
}
@@ -8,7 +8,11 @@ import (
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
// bareInternalSourceHandleRE matches WeKnora request-local source aliases
// leaked by a model (for example [d21], [c12], or [w3]). It intentionally
// does not match ordinary Markdown links or arbitrary bracketed content.
bareInternalSourceHandleRE = regexp.MustCompile(`(?i)\[(?:[dcw][0-9]+)(?:[ ]*[,,][ ]*(?:[dcw][0-9]+))*\]`)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
@@ -19,12 +23,13 @@ func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
if !displayReferenceMarkerRE.MatchString(content) && !bareInternalSourceHandleRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = bareInternalSourceHandleRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
@@ -34,10 +34,20 @@ func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
name: "unrelated double bracket syntax and ordinary link are preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "bare internal source handles",
input: "Nova支持虚拟控制器离线仿真[d21],并可搭配VX500[w3]。",
want: "Nova支持虚拟控制器离线仿真,并可搭配VX500。",
},
{
name: "grouped bare internal source handles",
input: "该结论由多个资料支持 [d21, c12,w3]。",
want: "该结论由多个资料支持。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
+2 -2
View File
@@ -629,7 +629,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -637,7 +637,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
@@ -0,0 +1,218 @@
package dingtalk
import (
"strings"
"unicode"
)
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
// display columns; a cell wider than this tends to collapse the column layout
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// 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
// still contains an over-wide cell into a grouped list — a bold row heading
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
if !strings.Contains(content, "|") {
return content
}
lines := splitMarkdownStreamLines(content)
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); {
line := lines[i]
if line.inFence {
out = append(out, line.text)
i++
continue
}
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
isCompleteTableDelimiter(lines[i+1].text) {
end := i + 1
for end+1 < len(lines) && !lines[end+1].inFence &&
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1])...)
i = end + 1
continue
}
out = append(out, line.text)
i++
}
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
continue
}
cells = append(cells, splitTableRow(row.text))
}
maxWidth := 0
for _, row := range cells {
for _, cell := range row {
if w := visualWidth(cell); w > maxWidth {
maxWidth = w
}
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
rebuilt[i] = row.text
}
return rebuilt
}
var rebuilt []string
for idx, row := range cells {
joined := "| " + strings.Join(row, " | ") + " |"
if idx == 0 {
rebuilt = append(rebuilt, joined)
delims := make([]string, len(row))
for c := range row {
delims[c] = "---"
}
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
} else {
rebuilt = append(rebuilt, joined)
}
}
return rebuilt
}
var out []string
for gi, row := range cells[1:] {
if gi > 0 {
out = append(out, "")
}
var title string
var rest []string
for c, cell := range row {
if c == 0 {
title = cell
continue
}
label := strings.TrimSpace(header[min(c, len(header)-1)])
if label == "" {
label = "第" + itoa(c+1) + "项"
}
if cell == "" {
continue
}
rest = append(rest, "- "+label+":"+cell)
}
if title == "" {
title = label2(row[0])
}
out = append(out, "**"+strings.Trim(title, "* ")+"**")
out = append(out, rest...)
}
return out
}
func blockHasBreak(rows []markdownStreamLine) bool {
for _, row := range rows {
lower := strings.ToLower(row.text)
if strings.Contains(lower, "<br") {
return true
}
}
return false
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := splitEscaped(trimmed)
for i := range parts {
cell := strings.TrimSpace(parts[i])
cell = strings.ReplaceAll(cell, "<br>", ";")
cell = strings.ReplaceAll(cell, "<br/>", ";")
cell = strings.ReplaceAll(cell, "<br />", ";")
cell = strings.ReplaceAll(cell, "\\|", "|")
parts[i] = strings.TrimSpace(cell)
}
return parts
}
// splitEscaped splits on unescaped pipe separators.
func splitEscaped(s string) []string {
var parts []string
var b strings.Builder
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 < len(s) && s[i+1] == '|' {
b.WriteString("\\|")
i++
continue
}
b.WriteByte(s[i])
case '|':
parts = append(parts, b.String())
b.Reset()
default:
b.WriteByte(s[i])
}
}
parts = append(parts, b.String())
return parts
}
func label2(cell string) string { return strings.Trim(cell, "* ") }
func min(a, b int) int {
if a < b {
return a
}
return b
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var d []byte
for n > 0 {
d = append([]byte{byte('0' + n%10)}, d...)
n /= 10
}
return string(d)
}
func visualWidth(s string) int {
w := 0
for _, r := range s {
if r == '\n' {
if w > 0 {
w++ // separator counted conservatively
}
continue
}
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
w += 2
continue
}
w++
}
return w
}
func fullwidthPunct(r rune) bool {
switch r {
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
return true
}
return false
}
@@ -0,0 +1,72 @@
package dingtalk
import (
"strings"
"testing"
)
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("compact table must pass through untouched, got %q", got)
}
}
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
// compact row (控制柜) from the same table is now a list entry too
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
}
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
t.Fatalf("every row must become a group, got %q", got)
}
if strings.Contains(got, "J1: 180°/s") == false {
t.Fatalf("cell content must be preserved, got %q", got)
}
}
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must be replaced, got %q", got)
}
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
t.Fatalf("short table must stay a table with ; separator, got %q", got)
}
}
func TestNormalizeSkipsFencedTables(t *testing.T) {
long := strings.Repeat("长", 30)
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("fenced content must never be rewritten, got %q", got)
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| a\\|b") {
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
}
if !strings.Contains(got, "a|b") {
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
}
if !strings.Contains(got, "第2项:") {
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
}
}
+35 -59
View File
@@ -6,36 +6,11 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
// returns when it cannot find relevant information. These are matched as
// substrings after trimming whitespace.
var noAnswerPhrases = []string{
"未找到",
"没有找到",
"无法确定",
"暂无相关",
"暂无已发布的权威知识",
"知识库中没有",
"产品不存在于资料中",
}
// retrievalGapPhrases are only considered a no-answer signal when the answer
// also directs the user to a human channel. Requiring both signals avoids
// misclassifying valid negative results such as "未查询到异常记录".
var retrievalGapPhrases = []string{
"未查询到",
"未检索到",
"未能查询到",
"未能检索到",
}
var humanEscalationPhrases = []string{
"建议您联系",
"建议联系",
"请联系",
"联系技术支持",
"联系官方销售",
}
// canonicalNoAnswer is the only response that carries the explicit-no-answer
// machine contract. It must be the complete answer, never a substring match:
// an otherwise supported answer may legitimately say that one specific detail
// was not found.
const canonicalNoAnswer = "当前暂无已发布的权威知识,暂不建议操作。"
// Evaluator classifies a successful turn into a coverage verdict.
type Evaluator struct{}
@@ -53,20 +28,20 @@ func (e *Evaluator) Version() string {
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Image turns (image media present) are classified by whether the question
// 2. Has references → covered_has_references (references are the coverage
// evidence; wording in a supported answer never overrides them)
// 3. Complete canonical no-answer response with no references
// → uncovered_explicit_no_answer
// 4. Image turns (image media present) are classified by whether the question
// actually triggered a knowledge-base search:
// - has references → covered_has_references
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns with zero references are classified by whether the question
// 5. Text turns with zero references are classified by whether the question
// actually triggered a knowledge-base search:
// - knowledge searched → uncovered_no_references
// - no knowledge search → covered_non_knowledge_response
// 5. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// v5 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
if turn.Status != "succeeded" {
@@ -76,9 +51,25 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
// Normalize answer for no-answer phrase detection.
trimmed := strings.TrimSpace(turn.Answer)
// Check for explicit no-answer phrases first — this takes priority
// over reference presence.
if isNoAnswer(trimmed) {
// References are the durable coverage evidence. A supported answer may
// correctly note that one sub-detail is absent, so its wording cannot turn
// the entire turn into a knowledge gap.
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
// An explicit abstention is a stable machine contract only when it is the
// complete answer. Do not use broad natural-language substring matching.
if isCanonicalNoAnswer(trimmed) {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
@@ -86,8 +77,8 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredExplicitNoAnswer,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
ReferenceCount: 0,
TopScore: 0,
}, nil
}
@@ -180,23 +171,8 @@ func hasImageMedia(media []core.MediaAudit) bool {
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
if strings.Contains(lower, strings.ToLower(phrase)) {
return true
}
}
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
}
func containsAny(value string, phrases []string) bool {
for _, phrase := range phrases {
if strings.Contains(value, strings.ToLower(phrase)) {
return true
}
}
return false
func isCanonicalNoAnswer(answer string) bool {
return strings.TrimSpace(answer) == canonicalNoAnswer
}
func maxScore(refs []core.Reference) float64 {
+45 -60
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v4" {
t.Errorf("Evaluator version = %q, want v4", e.Version())
if e.Version() != "v5" {
t.Errorf("Evaluator version = %q, want v5", e.Version())
}
}
@@ -30,55 +30,40 @@ func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
}
}
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
e := NewEvaluator()
noAnswerPhrases := []string{
"未找到相关结果",
"没有找到你需要的信息",
"无法确定该问题的答案",
"暂无相关信息",
"知识库中没有找到相关内容",
"这个问题知识库中没有可用的信息",
}
for _, answer := range noAnswerPhrases {
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: answer,
References: []core.Reference{
{ID: "r1", Score: 0.9},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error for answer %q: %v", answer, err)
}
if d.Verdict != VerdictUncovered {
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
}
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
func TestEvaluator_UncoveredCanonicalNoAnswerWithoutReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "missing-product-turn",
Status: "succeeded",
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
References: []core.Reference{
{ID: "unrelated-1", Score: 0.395940},
{ID: "unrelated-2", Score: 0.311595},
},
ID: "test-turn",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, VerdictUncovered, ReasonUncoveredExplicitNoAnswer)
}
}
func TestEvaluator_NovaPartialNotFoundWithSevenReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "nova-partial-turn",
Status: "succeeded",
Answer: "已找到Nova系列的相关资料;未找到VX500 2.5D视觉一体方案的明确说明。",
References: []core.Reference{
{ID: "r1"}, {ID: "r2"}, {ID: "r3"}, {ID: "r4"},
{ID: "r5"}, {ID: "r6"}, {ID: "r7"},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredHasReferences || d.ReferenceCount != 7 {
t.Fatalf("verdict/reason/references = %s/%s/%d, want %s/%s/7", d.Verdict, d.ReasonCode, d.ReferenceCount, VerdictCovered, ReasonCoveredHasReferences)
}
}
@@ -205,9 +190,9 @@ func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
}
}
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
// When answer contains explicit "no answer" phrase, it should be uncovered
// even if references exist. No-answer detection takes priority.
func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
// References are durable coverage evidence; a natural-language phrase must
// not override them.
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
@@ -222,11 +207,11 @@ func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
if d.Verdict != VerdictCovered {
t.Errorf("answer with references: verdict = %s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Errorf("answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
@@ -245,12 +230,12 @@ func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "cra-price-turn",
@@ -265,11 +250,11 @@ func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testi
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
if d.Verdict != VerdictCovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictCovered)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
@@ -282,10 +267,10 @@ func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
reason string
}{
{
name: "production NOVA price answer",
name: "retrieval wording with evidence remains covered",
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
verdict: VerdictUncovered,
reason: ReasonUncoveredExplicitNoAnswer,
verdict: VerdictCovered,
reason: ReasonCoveredHasReferences,
},
{
name: "retrieval gap without escalation is a valid negative result",
+6 -8
View File
@@ -104,14 +104,12 @@ func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "未找到相关结果",
References: []core.Reference{
{ID: "r1", Score: 0.5},
},
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+1 -1
View File
@@ -33,7 +33,7 @@ const (
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v4"
const EvaluatorVersion = "v5"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
-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)
+57 -8
View File
@@ -49,11 +49,28 @@ type Service struct {
repo Repository
quickAgentID string
reasoningAgentID string
defaultMode Mode
now func() time.Time
idleTimeout time.Duration
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
// Option customises a reasoning mode Service.
type Option func(*Service) error
// WithDefaultMode sets the mode applied to users who never explicitly chose
// one with /think. Passing ModeQuick (the default when unset) preserves the
// historical behaviour of answering with the quick agent.
func WithDefaultMode(mode Mode) Option {
return func(s *Service) error {
if mode != ModeQuick && mode != ModeReasoning {
return fmt.Errorf("invalid default reasoning mode %q", mode)
}
s.defaultMode = mode
return nil
}
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string, opts ...Option) (*Service, error) {
quickAgentID = strings.TrimSpace(quickAgentID)
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
if repo == nil {
@@ -65,13 +82,29 @@ func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Servic
if quickAgentID == reasoningAgentID {
return nil, errors.New("quick and reasoning agent IDs must differ")
}
return &Service{
service := &Service{
repo: repo,
quickAgentID: quickAgentID,
reasoningAgentID: reasoningAgentID,
defaultMode: ModeQuick,
now: time.Now,
idleTimeout: defaultIdleTimeout,
}, nil
}
for _, opt := range opts {
if err := opt(service); err != nil {
return nil, err
}
}
return service, nil
}
// effectivelyReasoning reports whether a turn should be served by the
// reasoning agent given the resolved state and the configured default mode.
func (s *Service) effectivelyReasoning(state State) bool {
if state.Mode == ModeReasoning {
return true
}
return !state.UserSelected && s.defaultMode == ModeReasoning
}
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
@@ -95,10 +128,14 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
state.MaxRounds,
)}, nil
case CommandDisable:
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
previous, _, err := s.repo.ResolveState(ctx, scope, s.now())
if err != nil {
return Decision{}, err
}
wasReasoning := s.effectivelyReasoning(previous)
if _, _, err = s.repo.Disable(ctx, scope, s.now()); err != nil {
return Decision{}, err
}
if !wasReasoning {
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
}
@@ -106,7 +143,7 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
case CommandSingle:
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
case CommandHelp:
return Decision{Handled: true, Reply: helpReply()}, nil
return Decision{Handled: true, Reply: helpReply(s.defaultMode)}, nil
default:
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
}
@@ -119,13 +156,25 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
if state.Mode == ModeReasoning {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
}
// The user never chose a mode explicitly: follow the operator default.
// Default-mode reasoning runs as a single reasoning turn so it never
// enters the rounds/idle-expiry machinery; the user can pin quick mode
// any time with /think off.
if s.effectivelyReasoning(state) {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
}
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
}
func helpReply() string {
func helpReply(defaultMode Mode) string {
defaultLabel := "默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n"
if defaultMode == ModeReasoning {
defaultLabel = "默认模式:智能推理模式,支持多步思考与工具调用,效果更强、计费标准更高。\n" +
"快速问答模式:基于知识库快速准确作答,响应快、成本低,可用 /think off 切换并保持。\n"
}
return "一、模式说明\n" +
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
defaultLabel + "\n" +
"二、模式切换指令\n" +
"/think + 你的问题:仅当前问题使用智能推理\n" +
"/think on:开启持续智能推理模式\n" +
+86
View File
@@ -23,6 +23,7 @@ func (f *fakeRepository) ResolveState(context.Context, Scope, time.Time) (State,
func (f *fakeRepository) Enable(context.Context, Scope, time.Time, time.Duration) (State, error) {
f.enableCalls++
f.state.Mode = ModeReasoning
f.state.UserSelected = true
f.state.RoundsUsed = 0
return f.state, nil
}
@@ -31,6 +32,7 @@ func (f *fakeRepository) Disable(context.Context, Scope, time.Time) (State, bool
f.disableCalls++
wasReasoning := f.state.Mode == ModeReasoning
f.state.Mode = ModeQuick
f.state.UserSelected = true
f.state.RoundsUsed = 0
return f.state, wasReasoning, nil
}
@@ -177,3 +179,87 @@ func TestPrepareReturnsLocalHelpWithoutStateMutation(t *testing.T) {
t.Fatalf("help mutated mode: %+v", repo)
}
}
func newTestServiceWithDefault(t *testing.T, repo Repository, mode Mode) *Service {
t.Helper()
service, err := NewService(repo, "builtin-quick-answer", "builtin-smart-reasoning", WithDefaultMode(mode))
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) }
return service
}
func TestNewServiceRejectsInvalidDefaultMode(t *testing.T) {
if _, err := NewService(&fakeRepository{}, "q", "r", WithDefaultMode("turbo")); err == nil {
t.Fatal("expected invalid default mode to fail")
}
}
func TestPrepareDefaultReasoningRoutesUnselectedUserToReasoningSingle(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestServiceWithDefault(t, repo, ModeReasoning).Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-smart-reasoning" || decision.Execution != ExecutionReasoningSingle {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareDefaultReasoningHonoursUserQuickSelection(t *testing.T) {
state := DefaultState()
state.UserSelected = true
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: state}, ModeReasoning).
Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareDefaultQuickKeepsLegacyBehaviour(t *testing.T) {
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeQuick).
Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
t.Fatalf("decision=%+v", decision)
}
}
func TestThinkOffPinsQuickModeUnderReasoningDefault(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
service := newTestServiceWithDefault(t, repo, ModeReasoning)
decision, err := service.Prepare(context.Background(), testScope(), "/think off")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || !strings.Contains(decision.Reply, "切回") {
t.Fatalf("decision=%+v", decision)
}
if !repo.state.UserSelected || repo.state.Mode != ModeQuick {
t.Fatalf("state=%+v", repo.state)
}
followUp, err := service.Prepare(context.Background(), testScope(), "后续问题")
if err != nil {
t.Fatal(err)
}
if followUp.AgentID != "builtin-quick-answer" {
t.Fatalf("follow-up escaped user quick selection: %+v", followUp)
}
}
func TestHelpReflectsReasoningDefaultMode(t *testing.T) {
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeReasoning).
Prepare(context.Background(), testScope(), "/help")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(decision.Reply, "默认模式:智能推理模式") {
t.Fatalf("help=%q", decision.Reply)
}
}
+1
View File
@@ -22,6 +22,7 @@ var ErrInvalidState = errors.New("invalid reasoning mode state")
type State struct {
Mode Mode
UserSelected bool
MaxRounds int
RoundsUsed int
HintEnabled bool
+8 -6
View File
@@ -61,6 +61,7 @@ func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.
return reasoningmode.State{}, err
}
state.Mode = reasoningmode.ModeReasoning
state.UserSelected = true
state.RoundsUsed = 0
state.LastActivityAt = now
state.ExpiresAt = now.Add(idle)
@@ -92,6 +93,7 @@ func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time
state, _ = reasoningmode.EffectiveState(state, now)
wasReasoning := state.Mode == reasoningmode.ModeReasoning
state.Mode = reasoningmode.ModeQuick
state.UserSelected = true
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
@@ -256,14 +258,14 @@ func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Sc
}
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3
FOR UPDATE`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
func selectReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
@@ -276,7 +278,7 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
expires pgtype.Timestamptz
onboarding pgtype.Timestamptz
)
if err := row.Scan(&mode, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
if err := row.Scan(&mode, &state.UserSelected, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
return reasoningmode.State{}, fmt.Errorf("scan reasoning state: %w", err)
}
state.Mode = reasoningmode.Mode(mode)
@@ -295,11 +297,11 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
func updateReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope, state reasoningmode.State, now time.Time) error {
_, err := tx.Exec(ctx, `UPDATE reasoning_mode_states SET
mode=$4,max_rounds=$5,rounds_used=$6,hint_enabled=$7,
last_activity_at=$8,expires_at=$9,onboarding_shown_at=$10,updated_at=$11
mode=$4,user_selected=$5,max_rounds=$6,rounds_used=$7,hint_enabled=$8,
last_activity_at=$9,expires_at=$10,onboarding_shown_at=$11,updated_at=$12
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`,
scope.TenantKey, scope.Channel, scope.ExternalActorID,
string(state.Mode), state.MaxRounds, state.RoundsUsed, state.HintEnabled,
string(state.Mode), state.UserSelected, state.MaxRounds, state.RoundsUsed, state.HintEnabled,
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
)
if err != nil {
+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)
}
}