feat(agent,channel): scrub fake generated-file URLs + paste-body hint for public-account articles

This commit is contained in:
matevip 2026-05-10 19:15:44 +08:00
parent 19a8c9e16c
commit 00098fe5f1
4 changed files with 118 additions and 3 deletions

View File

@ -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()
// 输入字段

View File

@ -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<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
@ -47,7 +64,7 @@ public class FinalAnswerNode implements NodeAction {
if (accessor.returnDirectTriggered()) {
List<DirectToolOutput> 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;

View File

@ -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.
* <p>

View File

@ -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<String, Entry> 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.
*
* <p>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();
}
}