mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent,channel): scrub fake generated-file URLs + paste-body hint for public-account articles
This commit is contained in:
parent
19a8c9e16c
commit
00098fe5f1
@ -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()
|
||||
// 输入字段
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user