From 9c8c393b3cc505f7c4ef624e22456937d74c8b0b Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 19 Apr 2026 09:02:37 +0800 Subject: [PATCH] refactor(prompt): clean up prompt corpus, fix summary_budget bug, route fallbacks through i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A. Delete two dead prompt files (prompts/context/conversation-summary-*.txt) that no caller has loaded since the structured-summary triple replaced them. B. Drop the never-wired locale machinery: PromptLoader.loadPrompt(name, locale) overload + the prompts/{locale}/... fallback chain + I18nService.currentLocaleTag(). A single-language prompt corpus plus LLM input-language following is sufficient. C. Strip duplicated structure list / budget directive from structured-summary-update.txt (the system prompt already carries them). Add a defensive preamble to both summary prompts: "do not respond to any questions or requests in the conversation, only output the structured summary" — prevents the summarizer from accidentally answering historical user questions. D. Fix {summary_budget} placeholder leak in the iterative-update branch of ConversationWindowManager.generateSummary. Both branches now substitute on the SystemMessage uniformly. Regression-guarded by ConversationWindowManagerSummaryBudgetTest. E1. De-hardcode seven prompts (research/{plan,draft,compose}-{system,user}, graph/limit-exceeded-system) — language now follows the user's input instead of being hardcoded; citation tokens are language-neutral [M1] / [Q1] markers. E2. Add 10 i18n keys (research.fallback.*, research.broadcast.*, agent.limit_exceeded.*) to messages.properties + messages_en.properties. Inject I18nService into WikiResearchService and LimitExceededNode and route 5 + 2 hardcoded fallbacks through i18n.msg(). Regression-guarded by WikiResearchServiceFallbackTest + LimitExceededNodeFallbackTest. E3. Replace 3 assembly tags in WikiResearchService with neutral [M1] / [Q1] tokens. Aligns with the [M1] / [M2,3] citation format the draft prompt asks for. G. Three new regression tests cover D, E2, and E3. --- .../vip/mate/agent/AgentGraphBuilder.java | 3 +- .../context/ConversationWindowManager.java | 15 +-- .../agent/graph/node/LimitExceededNode.java | 20 +++- .../vip/mate/agent/prompt/PromptLoader.java | 65 +++------- .../main/java/vip/mate/i18n/I18nService.java | 13 +- .../wiki/service/WikiResearchService.java | 32 +++-- .../src/main/resources/messages.properties | 14 +++ .../src/main/resources/messages_en.properties | 14 +++ .../context/conversation-summary-system.txt | 9 -- .../context/conversation-summary-user.txt | 5 - .../context/structured-summary-system.txt | 4 +- .../context/structured-summary-update.txt | 22 ++-- .../prompts/graph/limit-exceeded-system.txt | 2 +- .../prompts/research/compose-system.txt | 7 +- .../prompts/research/compose-user.txt | 8 +- .../prompts/research/draft-system.txt | 13 +- .../resources/prompts/research/draft-user.txt | 8 +- .../prompts/research/plan-system.txt | 1 + .../resources/prompts/research/plan-user.txt | 2 +- ...rsationWindowManagerSummaryBudgetTest.java | 109 +++++++++++++++++ .../node/LimitExceededNodeFallbackTest.java | 100 ++++++++++++++++ .../WikiResearchServiceFallbackTest.java | 112 ++++++++++++++++++ 22 files changed, 445 insertions(+), 133 deletions(-) delete mode 100644 mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt delete mode 100644 mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/LimitExceededNodeFallbackTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiResearchServiceFallbackTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 4420669b..2144c873 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -129,6 +129,7 @@ public class AgentGraphBuilder { private final vip.mate.llm.cache.LlmCacheMetricsAggregator llmCacheMetricsAggregator; private final vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage; private final vip.mate.tool.ToolConcurrencyRegistry toolConcurrencyRegistry; + private final vip.mate.i18n.I18nService i18nService; /** * 根据 AgentEntity 构建完整的 Agent 实例 @@ -354,7 +355,7 @@ public class AgentGraphBuilder { ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker); - LimitExceededNode limitExceededNode = new LimitExceededNode(chatModel, observationProcessor, streamingHelper); + LimitExceededNode limitExceededNode = new LimitExceededNode(chatModel, observationProcessor, streamingHelper, i18nService); FinalAnswerNode finalAnswerNode = new FinalAnswerNode(); KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder() diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index eeea1ee5..7b6782fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -435,19 +435,20 @@ public class ConversationWindowManager { String systemPrompt; String userPrompt; + // System prompt always carries the budget directive; both branches + // must replace the placeholder. The previous code applied the + // replace only on the first-compression branch, so iterative-mode + // calls leaked the literal "{summary_budget}" string to the LLM. + systemPrompt = STRUCTURED_SUMMARY_SYSTEM + .replace("{summary_budget}", String.valueOf(summaryBudget)); if (previousSummary != null) { - // 迭代更新模式:旧摘要 + 新轮次 - systemPrompt = STRUCTURED_SUMMARY_SYSTEM; + // Iterative update: previous summary + new turns. userPrompt = STRUCTURED_SUMMARY_UPDATE .replace("{previous_summary}", previousSummary) - .replace("{conversation}", conversationText) - .replace("{summary_budget}", String.valueOf(summaryBudget)); + .replace("{conversation}", conversationText); log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}", compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId); } else { - // 首次压缩 - systemPrompt = STRUCTURED_SUMMARY_SYSTEM - .replace("{summary_budget}", String.valueOf(summaryBudget)); userPrompt = STRUCTURED_SUMMARY_USER .replace("{conversation}", conversationText); log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java index 64ab56d0..f5da9d95 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java @@ -13,6 +13,7 @@ import vip.mate.agent.graph.observation.ObservationProcessor; import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.prompt.PromptLoader; +import vip.mate.i18n.I18nService; import java.util.ArrayList; import java.util.List; @@ -44,20 +45,28 @@ public class LimitExceededNode implements NodeAction { private final ChatModel chatModel; private final ObservationProcessor observationProcessor; private final NodeStreamingChatHelper streamingHelper; + /** Optional i18n service; nullable so legacy/tests without Spring context still work. */ + private final I18nService i18n; public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor, NodeStreamingChatHelper streamingHelper) { + this(chatModel, observationProcessor, streamingHelper, null); + } + + public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor, + NodeStreamingChatHelper streamingHelper, I18nService i18n) { this.chatModel = chatModel; this.observationProcessor = observationProcessor; this.streamingHelper = streamingHelper; + this.i18n = i18n; } /** - * @deprecated Use constructor with NodeStreamingChatHelper + * @deprecated use the constructor with {@link NodeStreamingChatHelper} (and optionally {@link I18nService}) */ @Deprecated public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) { - this(chatModel, observationProcessor, null); + this(chatModel, observationProcessor, null, null); } @Override @@ -88,7 +97,7 @@ public class LimitExceededNode implements NodeAction { contextForLLM = observationProcessor.truncate(sb.toString(), observationProcessor.getMaxTotalObservationChars()); } else { - contextForLLM = "(尚未收集到工具调用结果)"; + contextForLLM = i18n != null ? i18n.msg("agent.limit_exceeded.empty_context") : "(no tool results)"; } // 构建 prompt @@ -110,8 +119,11 @@ public class LimitExceededNode implements NodeAction { log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars", finalDraft != null ? finalDraft.length() : 0); + String fallbackMsg = i18n != null + ? i18n.msg("agent.limit_exceeded.fallback") + : "Sorry, the maximum reasoning steps were reached."; return MateClawStateAccessor.output() - .finalAnswerDraft(finalDraft != null ? finalDraft : "抱歉,已达到最大推理步数,未能获得完整结果。") + .finalAnswerDraft(finalDraft != null ? finalDraft : fallbackMsg) .currentThinking(result.thinking()) .limitExceeded(true) .contentStreamed(true) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/prompt/PromptLoader.java b/mateclaw-server/src/main/java/vip/mate/agent/prompt/PromptLoader.java index 58a07fa7..78b2c78d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/prompt/PromptLoader.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/prompt/PromptLoader.java @@ -9,14 +9,14 @@ import java.nio.charset.StandardCharsets; import java.util.concurrent.ConcurrentHashMap; /** - * Prompt 文件加载器 - *

- * 从 classpath:/prompts/ 目录加载 .txt 文件,使用 ConcurrentHashMap 做线程安全的懒加载缓存。 - *

- * 未来扩展点:可在 loadPrompt() 中增加"先查数据库覆盖 → 再读 resource → 最后代码兜底"的优先级链, - * 但本次只实现 resource 读取。 + * Loads prompt text files from {@code classpath:/prompts/} with a thread-safe + * lazy cache. * - * @author MateClaw Team + *

Single-language by design: prompts are written in the system's default + * language and the LLM is trusted to follow the user's input language for + * its output. The previous {@code loadPrompt(name, locale)} overload and + * {@code prompts/{locale}/...} fallback chain were never wired up by any + * caller and have been removed.

*/ @Slf4j public final class PromptLoader { @@ -28,44 +28,19 @@ public final class PromptLoader { private PromptLoader() {} /** - * 加载 prompt 文件内容(默认语言) + * Load a prompt file's contents. * - * @param promptName 文件名(不含路径前缀和 .txt 后缀),例如 "graph/summarize-system" - * @return 文件文本内容 - * @throws RuntimeException 文件不存在或读取失败时抛出,不会静默返回空字符串 + * @param promptName file name without the {@code prompts/} prefix or {@code .txt} suffix + * (e.g. {@code "graph/summarize-system"}) + * @return file text content + * @throws RuntimeException when the file is missing or unreadable; the loader never + * silently returns an empty string */ public static String loadPrompt(String promptName) { - return promptCache.computeIfAbsent(promptName, name -> readPromptFile(name, null)); + return promptCache.computeIfAbsent(promptName, PromptLoader::readPromptFile); } - /** - * 加载指定语言的 prompt 文件内容 - *

- * 查找顺序:{@code prompts/{locale}/{name}.txt} → {@code prompts/{name}.txt} - * - * @param promptName 文件名(不含路径前缀和 .txt 后缀) - * @param locale 语言标识(如 "en"、"zh"),为 null 或 "zh" 时使用默认文件 - * @return 文件文本内容 - */ - public static String loadPrompt(String promptName, String locale) { - if (locale == null || locale.isBlank() || "zh".equals(locale)) { - return loadPrompt(promptName); - } - String cacheKey = locale + ":" + promptName; - return promptCache.computeIfAbsent(cacheKey, key -> readPromptFile(promptName, locale)); - } - - private static String readPromptFile(String name, String locale) { - // 优先尝试 locale 目录 - if (locale != null && !locale.isBlank()) { - String localeFileName = PROMPT_PATH_PREFIX + locale + "/" + name + ".txt"; - try (InputStream is = PromptLoader.class.getClassLoader().getResourceAsStream(localeFileName)) { - if (is != null) { - return StreamUtils.copyToString(is, StandardCharsets.UTF_8); - } - } catch (IOException ignored) {} - } - // 回退到默认目录 + private static String readPromptFile(String name) { String fileName = PROMPT_PATH_PREFIX + name + ".txt"; try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) { if (inputStream == null) { @@ -78,18 +53,12 @@ public final class PromptLoader { } } - /** - * 清空缓存 - */ + /** Drop the entire cache. Useful for tests and hot-reload tooling. */ public static void clearCache() { promptCache.clear(); } - /** - * 获取缓存大小 - * - * @return 已缓存的 prompt 数量 - */ + /** Number of prompts currently cached. */ public static int getCacheSize() { return promptCache.size(); } diff --git a/mateclaw-server/src/main/java/vip/mate/i18n/I18nService.java b/mateclaw-server/src/main/java/vip/mate/i18n/I18nService.java index 685aa85e..b9231d06 100644 --- a/mateclaw-server/src/main/java/vip/mate/i18n/I18nService.java +++ b/mateclaw-server/src/main/java/vip/mate/i18n/I18nService.java @@ -46,17 +46,8 @@ public class I18nService { } /** - * 获取当前语言的 locale 标识(用于 PromptLoader 等需要 locale 字符串的场景) - * - * @return "zh" 或 "en" - */ - public String currentLocaleTag() { - String lang = settingService.getLanguage(); - return lang.startsWith("en") ? "en" : "zh"; - } - - /** - * 清除缓存的 Locale(语言切换时调用) + * Clear the cached Locale. Call after a language switch so that the next + * {@link #msg(String, Object...)} call re-reads {@code SystemSettingService.getLanguage()}. */ public void clearLocaleCache() { cachedLocale = null; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java index 38b0aa10..82cb55d2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiResearchService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.i18n.I18nService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -50,6 +51,7 @@ public class WikiResearchService { private final ModelConfigService modelConfigService; private final AgentGraphBuilder agentGraphBuilder; private final ChatStreamTracker streamTracker; + private final I18nService i18n; private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build(); private static final ExecutorService EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -73,8 +75,8 @@ public class WikiResearchService { // Stage 1: Plan List questions = planStage(topic); if (questions.isEmpty()) { - broadcast(sessionId, "research.error", Map.of("message", "主题无法分解为可研究的子问题")); - return new ResearchResult(topic, List.of(), "无法为该主题生成研究计划。"); + broadcast(sessionId, "research.error", Map.of("message", i18n.msg("research.broadcast.no_plan"))); + return new ResearchResult(topic, List.of(), i18n.msg("research.fallback.no_plan")); } broadcast(sessionId, "research.plan", Map.of( "questions", questions.stream().map(q -> Map.of("question", q.question, "intent", q.intent)).toList() @@ -83,8 +85,8 @@ public class WikiResearchService { // Stage 2: Retrieve + Draft (并行) List

sections = draftStage(kbId, questions, topK, sessionId); if (sections.stream().allMatch(s -> s.content == null || s.content.isBlank())) { - broadcast(sessionId, "research.error", Map.of("message", "所有子问题都未能起草出内容")); - return new ResearchResult(topic, sections, "没有足够的材料回答该主题。"); + broadcast(sessionId, "research.error", Map.of("message", i18n.msg("research.broadcast.draft_all_empty"))); + return new ResearchResult(topic, sections, i18n.msg("research.fallback.no_materials")); } // Stage 3: Compose @@ -97,8 +99,8 @@ public class WikiResearchService { return new ResearchResult(topic, sections, report); } catch (Exception e) { log.error("[Research] Failed: kbId={}, topic={}: {}", kbId, topic, e.getMessage(), e); - broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : "研究失败")); - return new ResearchResult(topic, List.of(), "研究过程失败: " + e.getMessage()); + broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : i18n.msg("research.broadcast.failed"))); + return new ResearchResult(topic, List.of(), i18n.msg("research.fallback.failed", e.getMessage())); } } @@ -174,10 +176,13 @@ public class WikiResearchService { List hits = hybridRetriever.searchChunks(kbId, q.question, topK); if (hits.isEmpty()) { - return new Section(q.question, "现有材料中未找到与该问题相关的内容。", List.of()); + return new Section(q.question, i18n.msg("research.fallback.no_materials_for_question"), List.of()); } - // 装配材料文本(带编号) + // Assemble material snippets with neutral [M1]/[M2]... markers so the + // LLM is not biased toward any output language. The same `[Mn]` token + // is the citation format the draft prompt asks for, so the model can + // refer to materials without translation. StringBuilder materials = new StringBuilder(); List refs = new ArrayList<>(); Map rawTitleCache = new HashMap<>(); @@ -188,8 +193,7 @@ public class WikiResearchService { WikiRawMaterialEntity raw = rawService.getById(id); return raw != null ? raw.getTitle() : "unknown"; }); - materials.append("### 材料 ").append(i + 1) - .append("(来自《").append(rawTitle).append("》)\n") + materials.append("### [M").append(i + 1).append("] Source: ").append(rawTitle).append("\n") .append(hit.snippet()) .append("\n\n"); refs.add(new MaterialRef(i + 1, hit.chunkId(), hit.rawId(), rawTitle)); @@ -203,7 +207,7 @@ public class WikiResearchService { String content = callLlm(systemPrompt, userPrompt, "draft: " + q.question); if (content == null || content.isBlank()) { - content = "现有材料不足以回答该子问题。"; + content = i18n.msg("research.fallback.draft_empty"); } return new Section(q.question, content, refs); @@ -217,7 +221,9 @@ public class WikiResearchService { for (int i = 0; i < sections.size(); i++) { Section s = sections.get(i); - sectionsText.append("### 子问题 ").append(i + 1).append(":").append(s.question).append("\n"); + // Use neutral [Q1]/[Q2] tokens — keeps prompt and (in compose-failure + // fallback) the report itself language-independent. + sectionsText.append("### [Q").append(i + 1).append("] ").append(s.question).append("\n"); sectionsText.append(s.content).append("\n\n"); for (MaterialRef ref : s.materialRefs) { usedMaterials.putIfAbsent(ref.index, ref.rawTitle); @@ -226,7 +232,7 @@ public class WikiResearchService { StringBuilder materialsRef = new StringBuilder(); usedMaterials.forEach((idx, title) -> - materialsRef.append("- 材料 ").append(idx).append(":").append(title).append("\n")); + materialsRef.append("- [M").append(idx).append("] ").append(title).append("\n")); String systemPrompt = PromptLoader.loadPrompt("research/compose-system"); String userPrompt = PromptLoader.loadPrompt("research/compose-user") diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index e0237a8e..fbfe9cbf 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -241,3 +241,17 @@ guard.path.symlink_escape=\u8def\u5f84\u901a\u8fc7\u7b26\u53f7\u94fe\u63a5\u9003 context.current_time=[system-context] \u5f53\u524d\u65f6\u95f4: {0} {1} (Asia/Shanghai) context.working_dir=[system-context] \u5de5\u4f5c\u76ee\u5f55: {0} context.working_dir_hint=\u4f60\u53ea\u80fd\u5728\u6b64\u76ee\u5f55\u53ca\u5176\u5b50\u76ee\u5f55\u5185\u8bfb\u5199\u6587\u4ef6\u548c\u6267\u884c\u547d\u4ee4\u3002 + +# --- Wiki Research Fallback (RFC: prompt-cleanup) --- +research.fallback.no_plan=\u65e0\u6cd5\u4e3a\u8be5\u4e3b\u9898\u751f\u6210\u7814\u7a76\u8ba1\u5212\u3002 +research.fallback.no_materials=\u6ca1\u6709\u8db3\u591f\u7684\u6750\u6599\u56de\u7b54\u8be5\u4e3b\u9898\u3002 +research.fallback.no_materials_for_question=\u73b0\u6709\u6750\u6599\u4e2d\u672a\u627e\u5230\u4e0e\u8be5\u95ee\u9898\u76f8\u5173\u7684\u5185\u5bb9\u3002 +research.fallback.draft_empty=\u73b0\u6709\u6750\u6599\u4e0d\u8db3\u4ee5\u56de\u7b54\u8be5\u5b50\u95ee\u9898\u3002 +research.fallback.failed=\u7814\u7a76\u8fc7\u7a0b\u5931\u8d25: {0} +research.broadcast.no_plan=\u4e3b\u9898\u65e0\u6cd5\u5206\u89e3\u4e3a\u53ef\u7814\u7a76\u7684\u5b50\u95ee\u9898 +research.broadcast.draft_all_empty=\u6240\u6709\u5b50\u95ee\u9898\u90fd\u672a\u80fd\u8d77\u8349\u51fa\u5185\u5bb9 +research.broadcast.failed=\u7814\u7a76\u5931\u8d25 + +# --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- +agent.limit_exceeded.fallback=\u62b1\u6b49\uff0c\u5df2\u8fbe\u5230\u6700\u5927\u63a8\u7406\u6b65\u6570\uff0c\u672a\u80fd\u83b7\u5f97\u5b8c\u6574\u7ed3\u679c\u3002 +agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5177\u8c03\u7528\u7ed3\u679c\uff09 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index f6e01bc7..8aceb7f9 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -249,3 +249,17 @@ err.approval.not_found=Approval record not found or expired context.current_time=[system-context] Current time: {0} {1} (Asia/Shanghai) context.working_dir=[system-context] Working directory: {0} context.working_dir_hint=You can only read/write files and execute commands within this directory and its subdirectories. + +# --- Wiki Research Fallback (RFC: prompt-cleanup) --- +research.fallback.no_plan=Unable to generate a research plan for this topic. +research.fallback.no_materials=Not enough material to answer this topic. +research.fallback.no_materials_for_question=No relevant material was found in the knowledge base for this question. +research.fallback.draft_empty=Insufficient material to answer this sub-question. +research.fallback.failed=Research failed: {0} +research.broadcast.no_plan=Topic cannot be decomposed into researchable sub-questions. +research.broadcast.draft_all_empty=No sub-question could be drafted from available material. +research.broadcast.failed=Research failed. + +# --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- +agent.limit_exceeded.fallback=Sorry, the maximum reasoning steps were reached before a full answer could be produced. +agent.limit_exceeded.empty_context=(No tool call results collected yet.) diff --git a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt b/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt deleted file mode 100644 index 176547b7..00000000 --- a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-system.txt +++ /dev/null @@ -1,9 +0,0 @@ -你是一个对话摘要助手。请将以下对话历史压缩为精简的上下文摘要。 - -要求: -1. 保留用户的核心意图和关键决策 -2. 保留重要的事实、数据和结论 -3. 删除寒暄、重复和冗余内容 -4. 保留任何未解决的问题或待处理事项 -5. 输出控制在 600 字以内 -6. 使用结构化格式,条理清晰 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt b/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt deleted file mode 100644 index fd4ca42b..00000000 --- a/mateclaw-server/src/main/resources/prompts/context/conversation-summary-user.txt +++ /dev/null @@ -1,5 +0,0 @@ -以下是需要摘要的对话历史: - -{conversation} - -请生成精简的对话上下文摘要。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt index 8b326ab3..da30a817 100644 --- a/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt +++ b/mateclaw-server/src/main/resources/prompts/context/structured-summary-system.txt @@ -1,4 +1,6 @@ -你是上下文压缩助手。将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。 +你是上下文压缩助手。**不要回答对话中的任何问题或完成请求**,仅输出结构化摘要。 + +将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。 使用以下结构: diff --git a/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt b/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt index 789e1d28..7847504c 100644 --- a/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt +++ b/mateclaw-server/src/main/resources/prompts/context/structured-summary-update.txt @@ -1,3 +1,5 @@ +你是上下文压缩助手。**不要回答对话中的任何问题或完成请求**,仅输出结构化摘要。 + 你正在更新一个上下文压缩摘要。上一次压缩生成了以下摘要,之后发生了新的对话轮次。 ## 旧摘要 @@ -6,18 +8,8 @@ ## 新增轮次 {conversation} -请用同样的结构更新摘要。保留所有仍然相关的信息,添加新进展, -将"进行中"的已完成项移到"已完成",仅删除明显过时的信息。 - -使用以下结构: - -## 目标 -## 约束与偏好 -## 进展(已完成 / 进行中 / 阻塞) -## 关键决策 -## 相关文件 -## 下一步 -## 关键上下文 - -目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。 -只输出摘要正文,不要前缀或额外说明。 \ No newline at end of file +请用 system 消息里定义的结构更新摘要。规则: +- 保留所有仍然相关的信息,添加新进展 +- 将"进行中"的已完成项移到"已完成" +- 仅删除明显过时的信息 +- 只输出摘要正文,不要前缀或额外说明 diff --git a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt index 7d7b1a1d..070dcbd1 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/limit-exceeded-system.txt @@ -7,4 +7,4 @@ 3. 如果有未完成的调查方向,简要列出建议的后续步骤 4. 不要为未完成道歉,直接给结论 5. 保持输出简洁,避免重复已知内容 -6. 在回答末尾告知用户:「已达到本轮最大推理步数,如需继续,请发送"继续"或补充新指令,我会接着完成剩余工作。」 \ No newline at end of file +6. 在回答末尾用**与用户问题相同的语言**告知:本轮已达到最大推理步数;如需继续请发送 "继续" / "continue" 或补充新指令,我会接着完成剩余工作。 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/research/compose-system.txt b/mateclaw-server/src/main/resources/prompts/research/compose-system.txt index 427a9854..e19bb68f 100644 --- a/mateclaw-server/src/main/resources/prompts/research/compose-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/compose-system.txt @@ -5,7 +5,8 @@ 2. 开头加一句话概述(基于主题) 3. 每个子问题作为一个二级标题(## 标题) 4. 段落之间做必要的衔接,但不要引入新信息 -5. 末尾加一段"### 参考材料",列出所有段落中引用过的材料序号和对应的材料标题 -6. 输出 Markdown 格式 +5. **报告语言与 topic 一致**:topic 为英文,所有标题、概述、参考材料清单都用英文;topic 为中文则用中文 +6. 末尾加一段「参考材料」(中文)或「References」(英文),列出所有段落中引用过的材料编号(`[M1]`、`[M2]` 等)和对应的材料标题 +7. 输出 Markdown 格式 -如果某些段落说"材料不足",在报告中保留这个声明,不要掩盖。 +如果某些段落说"材料不足" / "Not enough material",在报告中保留这个声明,不要掩盖。 diff --git a/mateclaw-server/src/main/resources/prompts/research/compose-user.txt b/mateclaw-server/src/main/resources/prompts/research/compose-user.txt index 0bd43e3e..e5cda5b0 100644 --- a/mateclaw-server/src/main/resources/prompts/research/compose-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/compose-user.txt @@ -1,14 +1,14 @@ -## 研究主题 +## 研究主题 / Topic {topic} -## 已写好的段落 +## 已写好的段落 / Drafted sections {sections} -## 使用过的材料 +## 使用过的材料 / Materials used {materials_ref} --- -请组装为一份 Markdown 格式的综合研究报告。 +请组装为一份 Markdown 格式的综合研究报告(语言跟随 topic)。 diff --git a/mateclaw-server/src/main/resources/prompts/research/draft-system.txt b/mateclaw-server/src/main/resources/prompts/research/draft-system.txt index 33ec1cd9..585608f2 100644 --- a/mateclaw-server/src/main/resources/prompts/research/draft-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/draft-system.txt @@ -1,10 +1,11 @@ -你是一个研究助手。基于提供的**材料片段**,针对**子问题**写一段 150-300 字的中文回答。 +你是一个研究助手。基于提供的**材料片段**,针对**子问题**写一段简洁回答。 规则: -1. **只基于提供的材料片段**——不要引入外部知识、不要虚构 -2. **如果材料不足以回答,明确说"现有材料不足以回答"**,不要强行编造 -3. 语言简洁准确,适合作为综合报告的一节 -4. 不要使用 markdown 标题(##),输出纯段落文本 -5. 尽量在段末注明使用了哪个材料片段的序号,格式 `[材料 1]`、`[材料 2, 3]` +1. **回答的语言与子问题保持一致**(中文子问题用中文,英文子问题用英文)。中文长度约 150-300 字,英文按对应字数/词数自行调整 +2. **只基于提供的材料片段**——不要引入外部知识、不要虚构 +3. **如果材料不足以回答,明确说明"材料不足"**(中文)或 "Not enough material"(英文),不要强行编造 +4. 语言简洁准确,适合作为综合报告的一节 +5. 不要使用 markdown 标题(##),输出纯段落文本 +6. 在段末注明使用了哪个材料片段,引用格式 `[M1]`、`[M2, 3]`(语言中立的方括号 token,不要再用「材料 N」之类的中文) 输出只包含正文段落,不要任何额外说明。 diff --git a/mateclaw-server/src/main/resources/prompts/research/draft-user.txt b/mateclaw-server/src/main/resources/prompts/research/draft-user.txt index 78856afc..1d3bba1b 100644 --- a/mateclaw-server/src/main/resources/prompts/research/draft-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/draft-user.txt @@ -1,13 +1,13 @@ -## 子问题 +## 子问题 / Sub-question {question} -## 意图 +## 意图 / Intent {intent} -## 材料片段(按相关度排序) +## 材料片段(按相关度排序)/ Sources (ordered by relevance) {materials} --- -请基于上述材料片段写出 150-300 字的回答段落。 +请基于上述材料片段写出一段简洁回答(语言与子问题一致;中文约 150-300 字,英文按对应字数自适应)。 diff --git a/mateclaw-server/src/main/resources/prompts/research/plan-system.txt b/mateclaw-server/src/main/resources/prompts/research/plan-system.txt index 76765f3f..f59c6cca 100644 --- a/mateclaw-server/src/main/resources/prompts/research/plan-system.txt +++ b/mateclaw-server/src/main/resources/prompts/research/plan-system.txt @@ -3,6 +3,7 @@ 1. 独立可检索——能在知识库中找到直接相关的材料 2. 互相覆盖主题的不同侧面——避免重复,保证广度 3. 简短明确——一个子问题一句话表达 +4. **子问题与 intent 的语言与 topic 一致**:topic 为英文则用英文,中文则用中文 严格输出 JSON(不要 markdown 代码块包裹): diff --git a/mateclaw-server/src/main/resources/prompts/research/plan-user.txt b/mateclaw-server/src/main/resources/prompts/research/plan-user.txt index 6593169f..6df59e98 100644 --- a/mateclaw-server/src/main/resources/prompts/research/plan-user.txt +++ b/mateclaw-server/src/main/resources/prompts/research/plan-user.txt @@ -1,3 +1,3 @@ 研究主题:{topic} -请拆解为 3-5 个可独立检索的子问题。 +请拆解为 3-5 个可独立检索的子问题。子问题语言与 topic 保持一致(topic 为英文则子问题亦用英文)。 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java new file mode 100644 index 00000000..5c25415d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerSummaryBudgetTest.java @@ -0,0 +1,109 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.config.ConversationWindowProperties; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.workspace.conversation.ConversationService; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression test for the RFC: prompt-cleanup D bug — the iterative-update + * branch of {@link ConversationWindowManager#generateSummary} previously + * used the raw {@code STRUCTURED_SUMMARY_SYSTEM} template without + * substituting {@code {summary_budget}}, leaking the literal placeholder + * into the LLM prompt. + * + *

Both branches must now produce a SystemMessage where {@code {summary_budget}} + * is replaced by the configured budget number.

+ */ +class ConversationWindowManagerSummaryBudgetTest { + + private ConversationWindowManager manager; + private ChatModel chatModel; + + @BeforeEach + void setUp() { + ConversationWindowProperties props = new ConversationWindowProperties(); + MemoryManager memory = mock(MemoryManager.class); + ConversationService conv = mock(ConversationService.class); + manager = new ConversationWindowManager(props, memory, conv); + + chatModel = mock(ChatModel.class); + // Return a non-null, non-empty response so generateSummary stores the result. + Generation gen = new Generation(new org.springframework.ai.chat.messages.AssistantMessage("STUB SUMMARY"), + ChatGenerationMetadata.NULL); + ChatResponse response = new ChatResponse(List.of(gen)); + when(chatModel.call(any(Prompt.class))).thenReturn(response); + } + + @Test + @DisplayName("First-compression branch: {summary_budget} is substituted in SystemMessage") + void firstCompressionReplacesBudget() throws Exception { + Prompt sentPrompt = invokeGenerateSummaryAndCapture("conv-first", null); + SystemMessage system = (SystemMessage) sentPrompt.getInstructions().stream() + .filter(m -> m instanceof SystemMessage).findFirst().orElseThrow(); + String text = system.getText(); + assertFalse(text.contains("{summary_budget}"), + "first-compression: literal placeholder must not leak into the SystemMessage"); + assertTrue(text.matches("(?s).*\\d{2,}.*"), + "first-compression: SystemMessage should contain a numeric budget after substitution"); + } + + @Test + @DisplayName("Iterative-update branch: {summary_budget} is substituted in SystemMessage") + void iterativeUpdateReplacesBudget() throws Exception { + // Seed previousSummaries so generateSummary takes the iterative-update path. + Field f = ConversationWindowManager.class.getDeclaredField("previousSummaries"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap prev = (ConcurrentHashMap) f.get(manager); + prev.put("conv-iter", "PRIOR SUMMARY (placeholder for the iterative-update branch test)"); + + Prompt sentPrompt = invokeGenerateSummaryAndCapture("conv-iter", null); + SystemMessage system = (SystemMessage) sentPrompt.getInstructions().stream() + .filter(m -> m instanceof SystemMessage).findFirst().orElseThrow(); + String text = system.getText(); + assertFalse(text.contains("{summary_budget}"), + "iterative-update: literal placeholder must not leak into the SystemMessage (the bug regression guard)"); + } + + /** + * Reflectively invoke the private {@code generateSummary} method and capture + * the {@link Prompt} sent to the mocked {@link ChatModel}. + */ + private Prompt invokeGenerateSummaryAndCapture(String conversationId, String memoryExtra) throws Exception { + // Two synthetic user messages so serializeForSummary produces non-empty content. + List oldMessages = List.of( + new UserMessage("hello"), + new UserMessage("world")); + + Method m = ConversationWindowManager.class.getDeclaredMethod( + "generateSummary", List.class, ChatModel.class, String.class, int.class, String.class); + m.setAccessible(true); + m.invoke(manager, oldMessages, chatModel, conversationId, 1500, memoryExtra); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + org.mockito.Mockito.verify(chatModel).call(captor.capture()); + return captor.getValue(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/LimitExceededNodeFallbackTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/LimitExceededNodeFallbackTest.java new file mode 100644 index 00000000..58f79cfd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/LimitExceededNodeFallbackTest.java @@ -0,0 +1,100 @@ +package vip.mate.agent.graph.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.observation.ObservationProcessor; +import vip.mate.i18n.I18nService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static vip.mate.agent.graph.state.MateClawStateKeys.*; + +/** + * Verifies that {@link LimitExceededNode} surfaces fallback strings via + * {@link I18nService} (RFC: prompt-cleanup E2) instead of literal Chinese + * hardcodes. Two paths are covered: + * + *
    + *
  • Empty observation history — the inline {@code contextForLLM} + * defaults to {@code i18n.msg("agent.limit_exceeded.empty_context")}
  • + *
  • LLM returns empty text — the {@code finalAnswerDraft} fallback + * comes from {@code i18n.msg("agent.limit_exceeded.fallback")}
  • + *
+ */ +class LimitExceededNodeFallbackTest { + + private ChatModel chatModel; + private ObservationProcessor observationProcessor; + private NodeStreamingChatHelper streamingHelper; + private I18nService i18n; + + @BeforeEach + void setUp() { + chatModel = mock(ChatModel.class); + observationProcessor = mock(ObservationProcessor.class); + when(observationProcessor.getMaxTotalObservationChars()).thenReturn(24000); + when(observationProcessor.truncate(anyString(), anyInt())).thenAnswer(inv -> inv.getArgument(0)); + + streamingHelper = mock(NodeStreamingChatHelper.class); + + i18n = mock(I18nService.class); + when(i18n.msg("agent.limit_exceeded.empty_context")).thenReturn("CANNED_EMPTY_CTX"); + when(i18n.msg("agent.limit_exceeded.fallback")).thenReturn("CANNED_FALLBACK"); + } + + private LimitExceededNode createNode() { + return new LimitExceededNode(chatModel, observationProcessor, streamingHelper, i18n); + } + + @Test + @DisplayName("Empty LLM response → finalAnswerDraft uses i18n fallback (not Chinese literal)") + void emptyLlmResponse_usesI18nFallback() throws Exception { + // LLM returns null text → triggers the i18n fallback branch. + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + null, "", new AssistantMessage(""), List.of(), false, 0, 0); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map output = createNode().apply(buildStateWithObservations()); + + assertEquals("CANNED_FALLBACK", output.get(FINAL_ANSWER_DRAFT), + "finalAnswerDraft must come from i18n.msg(\"agent.limit_exceeded.fallback\") when the LLM returns nothing"); + } + + @Test + @DisplayName("Non-empty LLM response → finalAnswerDraft uses LLM text (i18n untouched)") + void nonEmptyLlmResponse_usesLlmText() throws Exception { + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "real answer", "", new AssistantMessage("real answer"), List.of(), false, 10, 5); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map output = createNode().apply(buildStateWithObservations()); + + assertEquals("real answer", output.get(FINAL_ANSWER_DRAFT)); + } + + private OverAllState buildStateWithObservations() { + Map map = new HashMap<>(); + map.put(CONVERSATION_ID, "test-conv"); + map.put(USER_MESSAGE, "hello"); + map.put(MAX_ITERATIONS, 5); + map.put(CURRENT_ITERATION, 5); + // Non-empty observations so contextForLLM doesn't take the empty-context branch + // (that branch is exercised separately by an integration test, hard to mock here + // because OverAllState.value() may return immutable empty list defaults). + map.put(OBSERVATION_HISTORY, List.of("obs1", "obs2")); + return new OverAllState(map); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiResearchServiceFallbackTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiResearchServiceFallbackTest.java new file mode 100644 index 00000000..8fdb4740 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiResearchServiceFallbackTest.java @@ -0,0 +1,112 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.i18n.I18nService; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies that {@link WikiResearchService} surfaces fallback strings via + * {@link I18nService} (RFC: prompt-cleanup E2) instead of literal Chinese + * hardcodes when the LLM produces empty output. The plan-stage failure path + * is exercised because it is the cleanest to mock — a null LLM response + * causes {@code planStage} to return an empty list, which triggers the + * {@code research.fallback.no_plan} branch. + */ +class WikiResearchServiceFallbackTest { + + private HybridRetriever hybridRetriever; + private WikiRawMaterialService rawService; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private ChatStreamTracker streamTracker; + private I18nService i18n; + private ChatModel chatModel; + private WikiResearchService service; + + @BeforeEach + void setUp() { + hybridRetriever = mock(HybridRetriever.class); + rawService = mock(WikiRawMaterialService.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + streamTracker = mock(ChatStreamTracker.class); + i18n = mock(I18nService.class); + chatModel = mock(ChatModel.class); + + ModelConfigEntity model = mock(ModelConfigEntity.class); + when(modelConfigService.getDefaultModel()).thenReturn(model); + when(agentGraphBuilder.buildRuntimeChatModel(any(), any())).thenReturn(chatModel); + // Empty-response stub: planStage will return List.of() and trigger the no_plan fallback. + ChatResponse empty = mock(ChatResponse.class); + when(empty.getResult()).thenReturn(null); + when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))).thenReturn(empty); + + when(i18n.msg(anyString())).thenAnswer(inv -> "I18N[" + inv.getArgument(0) + "]"); + when(i18n.msg(anyString(), any())).thenAnswer(inv -> "I18N[" + inv.getArgument(0) + "]"); + + service = new WikiResearchService( + hybridRetriever, rawService, modelConfigService, + agentGraphBuilder, streamTracker, i18n); + } + + @Test + @DisplayName("Empty plan response → report comes from i18n, not literal Chinese") + void noPlanFallback_usesI18n() { + WikiResearchService.ResearchResult result = service.research(1L, "Test topic", "sess-1", 5); + + assertEquals("I18N[research.fallback.no_plan]", result.report(), + "report must come from i18n.msg(\"research.fallback.no_plan\")"); + assertTrue(result.sections().isEmpty()); + + // Also verify the broadcast went through i18n (not a Chinese literal). + verify(i18n).msg("research.broadcast.no_plan"); + verify(i18n).msg("research.fallback.no_plan"); + // Critical regression guard: never emit the old literal. + assertFalse(result.report().contains("无法为该主题生成研究计划"), + "literal Chinese fallback must not leak through after the i18n migration"); + } + + // Note: the research() catch-block fallback (research.fallback.failed) is not unit-tested + // here because callLlm internally swallows LLM exceptions and returns null, so they never + // propagate up to research()'s try/catch. Triggering that branch cleanly would require + // mocking an exception inside draftStage (post planStage). Coverage is left to the + // end-to-end smoke test described in the RFC. + + @Test + @DisplayName("Compose-fallback path uses neutral [Q]/[M] tokens (no '### 子问题' / '- 材料')") + void composeFallbackUsesNeutralTokens() throws Exception { + // Reach into composeStage via reflection with a non-empty section list so the + // built-in fallback string is exercised. The compose LLM call returns null + // (chatModel mocked above), so composeStage falls through to the manual concat. + var section = new WikiResearchService.Section( + "What is X?", + "Answer body referencing [M1].", + List.of(new WikiResearchService.MaterialRef(1, 100L, 200L, "Source A"))); + + java.lang.reflect.Method compose = WikiResearchService.class.getDeclaredMethod( + "composeStage", String.class, List.class); + compose.setAccessible(true); + String fallbackReport = (String) compose.invoke(service, "Topic", List.of(section)); + + // E3 regression guard: the assembled fallback report must use neutral tokens. + assertFalse(fallbackReport.contains("### 子问题"), "must not contain Chinese assembly tag '### 子问题'"); + assertFalse(fallbackReport.contains("- 材料"), "must not contain Chinese assembly tag '- 材料'"); + assertTrue(fallbackReport.contains("[Q1]"), "must contain neutral [Q1] token"); + } +}