[#305] [Feature] Add knowledge base trust verification (#334)

Co-authored-by: SuperCoderMan521 <SuperCoderManqq.com>
This commit is contained in:
jack 2026-06-16 07:47:04 +08:00 committed by GitHub
parent d9a9d07704
commit 88be1f748a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 373 additions and 20 deletions

View File

@ -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<String> unsupportedReferences) {
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
+ "。请继续检索/读取相关证据后再下结论。";
}
/**

View File

@ -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<ToolCallback> 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<Message> 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<String> unsupportedReferences) {
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
+ "。请继续检索/读取相关证据后再下结论。";
}
private AssistantMessage.ToolCall deserializeToolCall(String json) {

View File

@ -20,7 +20,10 @@ import java.util.regex.Pattern;
public record SourceEvidenceLedger(
Set<String> sourcePaths,
Set<String> sourceSymbols,
Set<String> failedPaths
Set<String> failedPaths,
Set<String> wikiPageTitles,
Set<String> wikiChunkIds,
Set<SourceEvidenceLedger.WikiCitation> 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<ToolResponseMessage.ToolResponse> 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<String> unsupported = new LinkedHashSet<>();
LinkedHashSet<String> 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<Integer> 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<String> unsupported) {
if (wikiCitations.isEmpty()) {
return;
}
LinkedHashSet<Integer> 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<Integer> citationIndexesIn(String answer) {
LinkedHashSet<Integer> 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<String> sourcePaths = new LinkedHashSet<>();
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
private final LinkedHashSet<String> wikiPageTitles = new LinkedHashSet<>();
private final LinkedHashSet<String> wikiChunkIds = new LinkedHashSet<>();
private final LinkedHashSet<WikiCitation> 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);
}
}

View File

@ -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();
}

View File

@ -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());
}
}