mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
Co-authored-by: SuperCoderMan521 <SuperCoderManqq.com>
This commit is contained in:
parent
d9a9d07704
commit
88be1f748a
@ -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)
|
||||
+ "。请继续读取相关文件后再下结论。";
|
||||
+ "。请继续检索/读取相关证据后再下结论。";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user