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 0db1585d..7ddec09d 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 @@ -175,6 +175,7 @@ public class FinalAnswerNode implements NodeAction { // validation so the validator sees the user-visible warning rather // than treating the fake link as a "reference". finalAnswer = scrubFakeUrls(finalAnswer); + finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer); SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer); if (finishReason == FinishReason.NORMAL && !validation.valid()) { @@ -236,9 +237,9 @@ public class FinalAnswerNode implements NodeAction { } private static String appendEvidenceWarning(String answer, List unsupportedReferences) { - return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:" + return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:" + String.join(", ", unsupportedReferences) - + "。请继续读取相关文件后再下结论。"; + + "。请继续检索/读取相关证据后再下结论。"; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index ff4c4c22..eb20cd5b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -271,6 +271,23 @@ public class ReasoningNode implements NodeAction { + " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n" + "**例外**:单一问题、简单问答、不可拆解的请求 — 不需要用。\n"; + private static final String GROUNDED_CONTRACT = "\n\n" + + "## 回答来源约束(强制规则)\n\n" + + "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n" + + "**必须遵守**:\n" + + "1. **仅据证据作答**:如果工具返回的信息不足以回答问题,必须明确说明\"根据现有信息无法回答此问题\"。\n" + + "2. **标记引用来源**:回答中引用的每个事实性陈述都必须用方括号数字标记来源,例如 [1]、[2]。\n" + + "3. **文末列出来源**:在回答末尾列出所有引用的来源列表,格式为:\n" + + " [1] 页面标题 - 章节(如有)\n" + + " [2] 页面标题 - 章节(如有)\n" + + "4. **禁止捏造来源**:不得引用未在本次对话中通过工具获取的页面或文件。\n" + + "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n" + + "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n"; + + private static String buildGroundedSystemPrompt(String basePrompt) { + return basePrompt + TOOL_USE_ENFORCEMENT + GROUNDED_CONTRACT; + } + private final ChatModel chatModel; private final List toolCallbacks; /** @@ -525,17 +542,14 @@ public class ReasoningNode implements NodeAction { // ======= 构建 Prompt ======= String systemPrompt = accessor.systemPrompt(); - // Append a tool-use enforcement clause to every ReasoningNode call. - // Without it, some models (notably DeepSeek thinking and Claude Opus) - // tend to "narrate" — emit a final_answer like "现在直接生成立项材料 - // docx" instead of actually calling renderDocx, which makes the - // graph silently terminate at final_answer_node with the narration - // as the user-facing reply. + // Append tool-use enforcement and grounded contract to every ReasoningNode call. + // Without tool-use enforcement, some models tend to "narrate" instead of calling tools. + // Grounded contract ensures answers are based only on evidence from tool results. // // Appended at runtime rather than woven into the AgentEntity-stored // prompt so it stays out of the user-editable agent UI but is still // always-on for the runtime LLM. - systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT; + systemPrompt = buildGroundedSystemPrompt(systemPrompt); List messages = accessor.messages(); // Per-loop budget: bound the working message list a single Reasoning @@ -960,12 +974,14 @@ public class ReasoningNode implements NodeAction { "iteration", accessor.iterationCount(), "answerChars", content != null ? content.length() : 0 )); + String answerWithSources = accessor.sourceEvidenceLedger() + .appendWikiSourceTable(content != null ? content : ""); SourceEvidenceLedger.Validation validation = - accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : ""); + accessor.sourceEvidenceLedger().validateAnswer(answerWithSources); boolean evidenceInsufficient = !validation.valid(); String finalAnswer = evidenceInsufficient ? evidenceWarning(validation.unsupportedReferences()) - : (content != null ? content : ""); + : answerWithSources; if (evidenceInsufficient) { log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}", validation.unsupportedReferences()); @@ -988,7 +1004,7 @@ public class ReasoningNode implements NodeAction { .currentPhase("reasoning") .streamedContent(evidenceInsufficient ? (content != null ? content : "") : "") .finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL) - .contentStreamed(!evidenceInsufficient) + .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : "")) .thinkingStreamed(!result.thinking().isEmpty()) .llmCallCount(nextLlmCallCount) .mergeUsage(state, result) @@ -998,9 +1014,9 @@ public class ReasoningNode implements NodeAction { } private static String evidenceWarning(List unsupportedReferences) { - return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:" + return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:" + String.join(", ", unsupportedReferences) - + "。请继续读取相关文件后再下结论。"; + + "。请继续检索/读取相关证据后再下结论。"; } private AssistantMessage.ToolCall deserializeToolCall(String json) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java index 1a839581..3ecfbea0 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/SourceEvidenceLedger.java @@ -20,7 +20,10 @@ import java.util.regex.Pattern; public record SourceEvidenceLedger( Set sourcePaths, Set sourceSymbols, - Set failedPaths + Set failedPaths, + Set wikiPageTitles, + Set wikiChunkIds, + Set wikiCitations ) implements Serializable { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -31,15 +34,19 @@ public record SourceEvidenceLedger( "\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b"); private static final Pattern DECLARED_TYPE = Pattern.compile( "\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b"); + private static final Pattern CITATION_MARKER = Pattern.compile("\\[(\\d+)\\]"); public SourceEvidenceLedger { sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths); sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols); failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths); + wikiPageTitles = Set.copyOf(wikiPageTitles == null ? Set.of() : wikiPageTitles); + wikiChunkIds = Set.copyOf(wikiChunkIds == null ? Set.of() : wikiChunkIds); + wikiCitations = Set.copyOf(wikiCitations == null ? Set.of() : wikiCitations); } public static SourceEvidenceLedger empty() { - return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of()); + return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of()); } public static SourceEvidenceLedger fromToolResponses(List responses) { @@ -56,6 +63,7 @@ public record SourceEvidenceLedger( recordReadFile(data, builder); } else { recordPlainTextEvidence(data, builder); + recordWikiEvidence(data, builder); } } return builder.build(); @@ -69,9 +77,15 @@ public record SourceEvidenceLedger( sourcePaths.forEach(builder::sourcePath); sourceSymbols.forEach(builder::symbol); failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); other.sourcePaths.forEach(builder::sourcePath); other.sourceSymbols.forEach(builder::symbol); other.failedPaths.forEach(builder::failedPath); + other.wikiPageTitles.forEach(builder::wikiPageTitle); + other.wikiChunkIds.forEach(builder::wikiChunkId); + other.wikiCitations.forEach(builder::wikiCitation); return builder.build(); } @@ -80,12 +94,61 @@ public record SourceEvidenceLedger( sourcePaths.forEach(builder::sourcePath); sourceSymbols.forEach(builder::symbol); failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); builder.sourcePath(path); return builder.build(); } + public SourceEvidenceLedger withWikiPageTitle(String title) { + Builder builder = new Builder(); + sourcePaths.forEach(builder::sourcePath); + sourceSymbols.forEach(builder::symbol); + failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); + builder.wikiPageTitle(title); + return builder.build(); + } + + public SourceEvidenceLedger withWikiChunkId(String chunkId) { + Builder builder = new Builder(); + sourcePaths.forEach(builder::sourcePath); + sourceSymbols.forEach(builder::symbol); + failedPaths.forEach(builder::failedPath); + wikiPageTitles.forEach(builder::wikiPageTitle); + wikiChunkIds.forEach(builder::wikiChunkId); + wikiCitations.forEach(builder::wikiCitation); + builder.wikiChunkId(chunkId); + return builder.build(); + } + public boolean hasEvidence() { - return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty(); + return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty() + || hasWikiEvidence(); + } + + public boolean hasWikiEvidence() { + return !wikiPageTitles.isEmpty() || !wikiChunkIds.isEmpty() || !wikiCitations.isEmpty(); + } + + public boolean hasWikiPageTitle(String title) { + if (title == null || title.isBlank()) { + return false; + } + String normalized = title.trim(); + return wikiPageTitles.contains(normalized) + || wikiPageTitles.stream().anyMatch(t -> t.equalsIgnoreCase(normalized)); + } + + public boolean hasWikiChunkId(String chunkId) { + return chunkId != null && wikiChunkIds.contains(chunkId); + } + + public boolean hasWikiCitationIndex(int index) { + return wikiCitations.stream().anyMatch(c -> c.index() == index); } public boolean hasPath(String path) { @@ -103,6 +166,7 @@ public record SourceEvidenceLedger( } LinkedHashSet unsupported = new LinkedHashSet<>(); LinkedHashSet unsupportedFileStems = new LinkedHashSet<>(); + Matcher fileMatcher = JAVA_FILE_REF.matcher(answer); while (fileMatcher.find()) { String ref = fileMatcher.group(); @@ -111,6 +175,7 @@ public record SourceEvidenceLedger( unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length())); } } + Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer); while (symbolMatcher.find()) { String ref = symbolMatcher.group(); @@ -118,9 +183,85 @@ public record SourceEvidenceLedger( unsupported.add(ref); } } + + validateWikiCitations(answer, unsupported); + return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported)); } + public String appendWikiSourceTable(String answer) { + if (answer == null || answer.isBlank() || wikiCitations.isEmpty()) { + return answer; + } + LinkedHashSet used = citationIndexesIn(answer); + if (used.isEmpty()) { + return answer; + } + StringBuilder additions = new StringBuilder(); + for (Integer index : used) { + WikiCitation citation = wikiCitation(index); + if (citation == null || sourceLineFor(answer, index) != null) { + continue; + } + if (additions.isEmpty()) { + additions.append("\n\n来源:"); + } + additions.append("\n").append(citation.sourceLine()); + } + return additions.isEmpty() ? answer : answer + additions; + } + + private void validateWikiCitations(String answer, LinkedHashSet unsupported) { + if (wikiCitations.isEmpty()) { + return; + } + + LinkedHashSet indexes = citationIndexesIn(answer); + if (indexes.isEmpty()) { + unsupported.add("missing wiki citation [n]"); + return; + } + + for (Integer index : indexes) { + WikiCitation citation = wikiCitation(index); + if (citation == null) { + unsupported.add("wiki citation [" + index + "]"); + continue; + } + String sourceLine = sourceLineFor(answer, index); + if (sourceLine == null) { + unsupported.add("wiki source table [" + index + "]"); + } else if (!citation.matchesSourceLine(sourceLine)) { + unsupported.add("wiki source title for [" + index + "]"); + } + } + } + + private LinkedHashSet citationIndexesIn(String answer) { + LinkedHashSet indexes = new LinkedHashSet<>(); + Matcher citationMatcher = CITATION_MARKER.matcher(answer); + while (citationMatcher.find()) { + try { + indexes.add(Integer.parseInt(citationMatcher.group(1))); + } catch (NumberFormatException ignored) { + } + } + return indexes; + } + + private WikiCitation wikiCitation(int index) { + return wikiCitations.stream() + .filter(c -> c.index() == index) + .findFirst() + .orElse(null); + } + + private static String sourceLineFor(String answer, int index) { + Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+(.+)$"); + Matcher matcher = pattern.matcher(answer); + return matcher.find() ? matcher.group(1).trim() : null; + } + private boolean hasFileName(String fileName) { String normalized = normalizePath(fileName); return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized)); @@ -158,6 +299,56 @@ public record SourceEvidenceLedger( recordSymbols(text, builder); } + private static void recordWikiEvidence(String text, Builder builder) { + if (text == null || text.isBlank()) { + return; + } + try { + JsonNode root = MAPPER.readTree(text); + recordWikiArray(root.path("chunks"), builder); + recordWikiArray(root.path("pages"), builder); + String title = root.path("title").asText(""); + String rawTitle = root.path("rawTitle").asText(""); + if (!title.isBlank()) { + builder.wikiPageTitle(title); + builder.wikiCitation(new WikiCitation(1, "", title, "", null)); + } + if (!rawTitle.isBlank()) { + builder.wikiPageTitle(rawTitle); + builder.wikiCitation(new WikiCitation(1, "", rawTitle, "", null)); + } + } catch (Exception ignored) { + } + } + + private static void recordWikiArray(JsonNode nodes, Builder builder) { + if (!nodes.isArray()) { + return; + } + int ordinal = 1; + for (JsonNode node : nodes) { + int index = node.path("index").isInt() ? node.path("index").asInt() : ordinal; + String title = firstNonBlank(node.path("rawTitle").asText(""), node.path("title").asText("")); + String chunkId = node.path("chunkId").asText(""); + String section = node.path("section").asText(""); + Integer pageNumber = node.hasNonNull("pageNumber") ? node.path("pageNumber").asInt() : null; + if (!title.isBlank()) { + builder.wikiPageTitle(title); + } + if (!chunkId.isBlank()) { + builder.wikiChunkId(chunkId); + } + if (!title.isBlank() || !chunkId.isBlank()) { + builder.wikiCitation(new WikiCitation(index, chunkId, title, section, pageNumber)); + } + ordinal++; + } + } + + private static String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : (second == null ? "" : second); + } + private static void recordSymbols(String text, Builder builder) { Matcher matcher = DECLARED_TYPE.matcher(text); while (matcher.find()) { @@ -180,6 +371,9 @@ public record SourceEvidenceLedger( private final LinkedHashSet sourcePaths = new LinkedHashSet<>(); private final LinkedHashSet sourceSymbols = new LinkedHashSet<>(); private final LinkedHashSet failedPaths = new LinkedHashSet<>(); + private final LinkedHashSet wikiPageTitles = new LinkedHashSet<>(); + private final LinkedHashSet wikiChunkIds = new LinkedHashSet<>(); + private final LinkedHashSet wikiCitations = new LinkedHashSet<>(); void sourcePath(String path) { String normalized = normalizePath(path); @@ -207,8 +401,63 @@ public record SourceEvidenceLedger( } } + void wikiPageTitle(String title) { + if (title != null && !title.isBlank()) { + wikiPageTitles.add(title.trim()); + } + } + + void wikiChunkId(String chunkId) { + if (chunkId != null && !chunkId.isBlank()) { + wikiChunkIds.add(chunkId.trim()); + } + } + + void wikiCitation(WikiCitation citation) { + if (citation == null || citation.index() < 1) { + return; + } + wikiCitations.removeIf(existing -> existing.index() == citation.index()); + wikiCitations.add(citation.normalized()); + } + SourceEvidenceLedger build() { - return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths); + return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths, + wikiPageTitles, wikiChunkIds, wikiCitations); + } + } + + public record WikiCitation(int index, String chunkId, String title, + String section, Integer pageNumber) implements Serializable { + WikiCitation normalized() { + return new WikiCitation(index, + chunkId == null ? "" : chunkId.trim(), + title == null ? "" : title.trim(), + section == null ? "" : section.trim(), + pageNumber); + } + + String sourceLine() { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(index).append("] "); + sb.append(title == null || title.isBlank() ? "chunkId=" + chunkId : title); + if (section != null && !section.isBlank()) { + sb.append(" - ").append(section); + } + if (pageNumber != null) { + sb.append(" - page ").append(pageNumber); + } + return sb.toString(); + } + + boolean matchesSourceLine(String line) { + if (line == null || line.isBlank()) { + return false; + } + if (title != null && !title.isBlank() && line.contains(title)) { + return true; + } + return chunkId != null && !chunkId.isBlank() && line.contains(chunkId); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 53ae4cf9..dda6a0de 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -396,19 +396,20 @@ public class WikiTool { } JSONArray arr = new JSONArray(); + int index = 1; for (HybridRetriever.ChunkHit hit : hits) { cn.hutool.json.JSONObject obj = JSONUtil.createObj() + .set("index", index) .set("chunkId", hit.chunkId()) .set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown")) .set("snippet", hit.snippet()) .set("score", String.format("%.4f", hit.score())); - // RFC-051 PR-1c: surface chunk metadata when available so the agent - // can cite "page 12, section 'Setup / Linux'" rather than an opaque snippet. if (hit.pageNumber() != null) obj.set("pageNumber", hit.pageNumber()); if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) { obj.set("section", hit.headerBreadcrumb()); } arr.add(obj); + index++; } return JSONUtil.createObj() @@ -416,6 +417,7 @@ public class WikiTool { .set("query", query) .set("matchCount", hits.size()) .set("chunks", arr) + .set("citationHint", "引用格式示例:[1] 表示第一条结果,[2] 表示第二条结果。在回答末尾列出所有引用来源。") .toString(); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java index fa154e9c..add5ecc7 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java @@ -149,4 +149,89 @@ class SourceEvidenceLedgerTest { assertFalse(validation.valid()); assertTrue(validation.unsupportedReferences().contains("RandomMadeUpService")); } + + @Test + @DisplayName("records wiki semantic chunks as numbered citations") + void recordsWikiSemanticChunksAsCitations() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + { + "kbId": 7, + "query": "install", + "matchCount": 2, + "chunks": [ + {"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}, + {"index":2,"chunkId":102,"rawTitle":"FAQ","snippet":"Restart after install."} + ] + } + """))); + + assertTrue(ledger.hasWikiEvidence()); + assertTrue(ledger.hasWikiCitationIndex(1)); + assertTrue(ledger.hasWikiCitationIndex(2)); + assertFalse(ledger.hasWikiCitationIndex(3)); + } + + @Test + @DisplayName("rejects wiki answers without real numbered citations") + void rejectsWikiAnswerWithoutRealCitations() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","snippet":"Use the package manager."}]} + """))); + + SourceEvidenceLedger.Validation noMarker = ledger.validateAnswer("Use the package manager."); + assertFalse(noMarker.valid()); + assertTrue(noMarker.unsupportedReferences().contains("missing wiki citation [n]")); + + SourceEvidenceLedger.Validation unsupportedMarker = ledger.validateAnswer(""" + Use the package manager [2]. + + 来源: + [2] Install Guide + """); + assertFalse(unsupportedMarker.valid()); + assertTrue(unsupportedMarker.unsupportedReferences().contains("wiki citation [2]")); + } + + @Test + @DisplayName("requires wiki source table rows to match retrieved source titles") + void requiresWikiSourceTableToMatchTitles() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + SourceEvidenceLedger.Validation fabricatedTitle = ledger.validateAnswer(""" + Use the package manager [1]. + + 来源: + [1] Made Up Manual + """); + assertFalse(fabricatedTitle.valid()); + assertTrue(fabricatedTitle.unsupportedReferences().contains("wiki source title for [1]")); + + SourceEvidenceLedger.Validation valid = ledger.validateAnswer(""" + Use the package manager [1]. + + 来源: + [1] Install Guide - Linux - page 12 + """); + assertTrue(valid.valid()); + } + + @Test + @DisplayName("renders missing wiki source table for cited chunks") + void rendersWikiSourceTable() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """ + {"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]} + """))); + + String rendered = ledger.appendWikiSourceTable("Use the package manager [1]."); + + assertTrue(rendered.contains("来源:")); + assertTrue(rendered.contains("[1] Install Guide - Linux - page 12")); + assertTrue(ledger.validateAnswer(rendered).valid()); + } }