From 00098fe5f13d274e9e4f6be061093eab5d2691f9 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 10 May 2026 19:15:44 +0800 Subject: [PATCH] feat(agent,channel): scrub fake generated-file URLs + paste-body hint for public-account articles --- .../vip/mate/agent/AgentGraphBuilder.java | 3 +- .../agent/graph/node/FinalAnswerNode.java | 37 +++++++++++++- .../channel/wecom/WeComChannelAdapter.java | 32 ++++++++++++ .../tool/document/GeneratedFileCache.java | 49 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) 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 749cfbce..9c5d24b2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -131,6 +131,7 @@ public class AgentGraphBuilder { private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker; private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory; private final vip.mate.llm.failover.AvailableProviderPool providerPool; + private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; /** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */ private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder; private final vip.mate.llm.routing.MultimodalRouter multimodalRouter; @@ -565,7 +566,7 @@ public class AgentGraphBuilder { ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker); LimitExceededNode limitExceededNode = new LimitExceededNode(chatModel, observationProcessor, streamingHelper, i18nService); - FinalAnswerNode finalAnswerNode = new FinalAnswerNode(); + FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache); KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder() // 输入字段 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index aeee304d..3574a2ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -8,6 +8,7 @@ import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.graph.state.SourceEvidenceLedger; +import vip.mate.tool.document.GeneratedFileCache; import java.util.List; import java.util.Map; @@ -30,6 +31,22 @@ import java.util.Map; @Slf4j public class FinalAnswerNode implements NodeAction { + /** + * Cache used to vet {@code /api/v1/files/generated/{id}} URLs the LLM + * may have written into the final answer. {@code null} disables the + * guard (legacy callers, narrow unit tests that don't exercise file + * outputs). + */ + private final GeneratedFileCache generatedFileCache; + + public FinalAnswerNode() { + this(null); + } + + public FinalAnswerNode(GeneratedFileCache generatedFileCache) { + this.generatedFileCache = generatedFileCache; + } + @Override public Map apply(OverAllState state) throws Exception { MateClawStateAccessor accessor = new MateClawStateAccessor(state); @@ -47,7 +64,7 @@ public class FinalAnswerNode implements NodeAction { if (accessor.returnDirectTriggered()) { List outputs = accessor.directToolOutputs(); if (!outputs.isEmpty()) { - String assembled = assembleDirectAnswer(outputs); + String assembled = scrubFakeUrls(assembleDirectAnswer(outputs)); String currentThinking = accessor.currentThinking(); String existingThinking = accessor.finalThinking(); String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking; @@ -70,7 +87,7 @@ public class FinalAnswerNode implements NodeAction { // 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化 if (accessor.awaitingApproval()) { - String preservedContent = accessor.streamedContent(); + String preservedContent = scrubFakeUrls(accessor.streamedContent()); String preservedThinking = !accessor.streamedThinking().isEmpty() ? accessor.streamedThinking() : accessor.currentThinking(); log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " + @@ -139,6 +156,12 @@ public class FinalAnswerNode implements NodeAction { } } + // Scrub hallucinated `/api/v1/files/generated/{id}` URLs whose ids + // were never inserted into the cache. Done before evidence + // validation so the validator sees the user-visible warning rather + // than treating the fake link as a "reference". + finalAnswer = scrubFakeUrls(finalAnswer); + SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer); if (finishReason == FinishReason.NORMAL && !validation.valid()) { finishReason = FinishReason.EVIDENCE_INSUFFICIENT; @@ -196,6 +219,16 @@ public class FinalAnswerNode implements NodeAction { return sb.toString(); } + /** + * Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss) + * with a user-visible warning. No-op when no cache is wired (legacy + * tests) or when the answer is empty. + */ + private String scrubFakeUrls(String text) { + if (generatedFileCache == null || text == null || text.isEmpty()) return text; + return generatedFileCache.scrubMissingReferences(text); + } + private FinishReason parseFinishReason(String reason) { if (reason == null || reason.isEmpty()) { return FinishReason.NORMAL; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index bec82890..3c11ddb9 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -2772,6 +2772,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { if (!title.isBlank()) text.append(' ').append(title); if (!desc.isBlank()) text.append('\n').append(desc); text.append('\n').append(linkUrl); + // WeChat public-account articles (mp.weixin.qq.com) ship the + // body behind a captcha-gated SSR page — the URL is opaque to + // any LLM tool. Without this hint the model invents plausible + // content from the title alone (observed: "本文讲了三个要点…" + // hallucinations). The hint nudges the agent to ask the user + // to paste the article text instead of guessing. + if (isPublicAccountArticle(linkUrl)) { + text.append('\n').append(PUBLIC_ACCOUNT_ARTICLE_HINT); + } } else if (!title.isBlank()) { text.append("[appmsg: ").append(title).append("]"); } else { @@ -2780,6 +2789,29 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { return new AppmsgContent(text.toString(), attached); } + /** + * Hint appended to forwarded WeChat public-account articles. Worded as + * a directive for the agent (not a user-visible message) — the agent's + * reasoning prompt picks it up alongside the link itself, so the model + * sees the directive in-band with the share. + */ + static final String PUBLIC_ACCOUNT_ARTICLE_HINT = + "(提示:该链接为公众号文章,正文需要用户在微信内打开后复制粘贴," + + "请优先请用户粘贴正文,不要凭标题猜测内容。)"; + + /** + * Public-account article links are hosted on {@code mp.weixin.qq.com}. + * Compared to a generic URL host check, this is intentionally narrow — + * other Tencent properties (e.g. video.qq.com) don't share the same + * "title-only, body needs paste" property and shouldn't get the hint. + */ + static boolean isPublicAccountArticle(String url) { + if (url == null) return false; + String lower = url.toLowerCase(); + return lower.contains("://mp.weixin.qq.com/") + || lower.startsWith("mp.weixin.qq.com/"); + } + /** * Build a fully-populated image content part for inbound WeCom media. *

diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index e7bd8547..35ad1756 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -7,6 +7,8 @@ import java.time.Duration; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * In-memory cache of bytes produced by tools (e.g. {@code DocxRenderTool}) and @@ -23,6 +25,22 @@ public class GeneratedFileCache { public static final Duration TTL = Duration.ofMinutes(10); + /** + * URL pattern for in-memory generated files served by + * {@code GeneratedFileController}. Public so channel adapters and graph + * nodes share a single source of truth. + */ + public static final Pattern GENERATED_URL_PATTERN = + Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + + /** + * User-visible warning swapped in for a cache-miss URL. Identical + * wording to the channel-side fallback so users see one consistent + * message regardless of which surface (web, IM, etc.) renders it. + */ + public static final String MISSING_REFERENCE_NOTICE = + "⚠️ 文件未真正生成(模型未调用文档生成工具),请重新发送请求"; + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) { @@ -66,4 +84,35 @@ public class GeneratedFileCache { long now = System.currentTimeMillis(); entries.entrySet().removeIf(e -> e.getValue().expireAt() <= now); } + + /** + * Replace any {@code /api/v1/files/generated/{id}} URL in {@code text} + * whose id is NOT present (or has expired) in this cache with + * {@link #MISSING_REFERENCE_NOTICE}. URLs whose ids ARE in the cache are + * left intact so downstream channel adapters can still rewrite them + * into native attachments. + * + *

Cache misses are nearly always LLM hallucinations — the model + * emitted a UUID-shaped string without ever calling a render tool. + * Without this scrub, every channel that receives the answer (Web, + * Slack, DingTalk, Telegram, …) would render a clickable link that + * 404s, and IM clients save the 404 HTML body as a {@code .docx} + * which users then report as "corrupted file". + */ + public String scrubMissingReferences(String text) { + if (text == null || text.isEmpty()) return text; + Matcher m = GENERATED_URL_PATTERN.matcher(text); + if (!m.find()) return text; + StringBuilder out = new StringBuilder(); + m.reset(); + while (m.find()) { + String id = m.group(1); + Entry entry = entries.get(id); + boolean live = entry != null && !entry.expired(); + String replacement = live ? m.group(0) : MISSING_REFERENCE_NOTICE; + m.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + m.appendTail(out); + return out.toString(); + } }