mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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
|
// validation so the validator sees the user-visible warning rather
|
||||||
// than treating the fake link as a "reference".
|
// than treating the fake link as a "reference".
|
||||||
finalAnswer = scrubFakeUrls(finalAnswer);
|
finalAnswer = scrubFakeUrls(finalAnswer);
|
||||||
|
finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer);
|
||||||
|
|
||||||
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
|
||||||
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
|
||||||
@ -236,9 +237,9 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
|
||||||
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
+ String.join(", ", unsupportedReferences)
|
+ String.join(", ", unsupportedReferences)
|
||||||
+ "。请继续读取相关文件后再下结论。";
|
+ "。请继续检索/读取相关证据后再下结论。";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -271,6 +271,23 @@ public class ReasoningNode implements NodeAction {
|
|||||||
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\n"
|
+ " · ledger snapshot 永远显示初始状态,对你毫无帮助\n\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 ChatModel chatModel;
|
||||||
private final List<ToolCallback> toolCallbacks;
|
private final List<ToolCallback> toolCallbacks;
|
||||||
/**
|
/**
|
||||||
@ -525,17 +542,14 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
// ======= 构建 Prompt =======
|
// ======= 构建 Prompt =======
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
// Append a tool-use enforcement clause to every ReasoningNode call.
|
// Append tool-use enforcement and grounded contract to every ReasoningNode call.
|
||||||
// Without it, some models (notably DeepSeek thinking and Claude Opus)
|
// Without tool-use enforcement, some models tend to "narrate" instead of calling tools.
|
||||||
// tend to "narrate" — emit a final_answer like "现在直接生成立项材料
|
// Grounded contract ensures answers are based only on evidence from tool results.
|
||||||
// docx" instead of actually calling renderDocx, which makes the
|
|
||||||
// graph silently terminate at final_answer_node with the narration
|
|
||||||
// as the user-facing reply.
|
|
||||||
//
|
//
|
||||||
// Appended at runtime rather than woven into the AgentEntity-stored
|
// Appended at runtime rather than woven into the AgentEntity-stored
|
||||||
// prompt so it stays out of the user-editable agent UI but is still
|
// prompt so it stays out of the user-editable agent UI but is still
|
||||||
// always-on for the runtime LLM.
|
// always-on for the runtime LLM.
|
||||||
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
|
systemPrompt = buildGroundedSystemPrompt(systemPrompt);
|
||||||
List<Message> messages = accessor.messages();
|
List<Message> messages = accessor.messages();
|
||||||
|
|
||||||
// Per-loop budget: bound the working message list a single Reasoning
|
// Per-loop budget: bound the working message list a single Reasoning
|
||||||
@ -960,12 +974,14 @@ public class ReasoningNode implements NodeAction {
|
|||||||
"iteration", accessor.iterationCount(),
|
"iteration", accessor.iterationCount(),
|
||||||
"answerChars", content != null ? content.length() : 0
|
"answerChars", content != null ? content.length() : 0
|
||||||
));
|
));
|
||||||
|
String answerWithSources = accessor.sourceEvidenceLedger()
|
||||||
|
.appendWikiSourceTable(content != null ? content : "");
|
||||||
SourceEvidenceLedger.Validation validation =
|
SourceEvidenceLedger.Validation validation =
|
||||||
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
|
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
||||||
boolean evidenceInsufficient = !validation.valid();
|
boolean evidenceInsufficient = !validation.valid();
|
||||||
String finalAnswer = evidenceInsufficient
|
String finalAnswer = evidenceInsufficient
|
||||||
? evidenceWarning(validation.unsupportedReferences())
|
? evidenceWarning(validation.unsupportedReferences())
|
||||||
: (content != null ? content : "");
|
: answerWithSources;
|
||||||
if (evidenceInsufficient) {
|
if (evidenceInsufficient) {
|
||||||
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
|
||||||
validation.unsupportedReferences());
|
validation.unsupportedReferences());
|
||||||
@ -988,7 +1004,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
||||||
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
||||||
.contentStreamed(!evidenceInsufficient)
|
.contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : ""))
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.llmCallCount(nextLlmCallCount)
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
@ -998,9 +1014,9 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String evidenceWarning(List<String> unsupportedReferences) {
|
private static String evidenceWarning(List<String> unsupportedReferences) {
|
||||||
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
|
return "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
|
||||||
+ String.join(", ", unsupportedReferences)
|
+ String.join(", ", unsupportedReferences)
|
||||||
+ "。请继续读取相关文件后再下结论。";
|
+ "。请继续检索/读取相关证据后再下结论。";
|
||||||
}
|
}
|
||||||
|
|
||||||
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
||||||
|
|||||||
@ -20,7 +20,10 @@ import java.util.regex.Pattern;
|
|||||||
public record SourceEvidenceLedger(
|
public record SourceEvidenceLedger(
|
||||||
Set<String> sourcePaths,
|
Set<String> sourcePaths,
|
||||||
Set<String> sourceSymbols,
|
Set<String> sourceSymbols,
|
||||||
Set<String> failedPaths
|
Set<String> failedPaths,
|
||||||
|
Set<String> wikiPageTitles,
|
||||||
|
Set<String> wikiChunkIds,
|
||||||
|
Set<SourceEvidenceLedger.WikiCitation> wikiCitations
|
||||||
) implements Serializable {
|
) implements Serializable {
|
||||||
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
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");
|
"\\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(
|
private static final Pattern DECLARED_TYPE = Pattern.compile(
|
||||||
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
|
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
|
||||||
|
private static final Pattern CITATION_MARKER = Pattern.compile("\\[(\\d+)\\]");
|
||||||
|
|
||||||
public SourceEvidenceLedger {
|
public SourceEvidenceLedger {
|
||||||
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
|
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
|
||||||
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
|
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
|
||||||
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
|
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() {
|
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) {
|
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
|
||||||
@ -56,6 +63,7 @@ public record SourceEvidenceLedger(
|
|||||||
recordReadFile(data, builder);
|
recordReadFile(data, builder);
|
||||||
} else {
|
} else {
|
||||||
recordPlainTextEvidence(data, builder);
|
recordPlainTextEvidence(data, builder);
|
||||||
|
recordWikiEvidence(data, builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return builder.build();
|
return builder.build();
|
||||||
@ -69,9 +77,15 @@ public record SourceEvidenceLedger(
|
|||||||
sourcePaths.forEach(builder::sourcePath);
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
sourceSymbols.forEach(builder::symbol);
|
sourceSymbols.forEach(builder::symbol);
|
||||||
failedPaths.forEach(builder::failedPath);
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
other.sourcePaths.forEach(builder::sourcePath);
|
other.sourcePaths.forEach(builder::sourcePath);
|
||||||
other.sourceSymbols.forEach(builder::symbol);
|
other.sourceSymbols.forEach(builder::symbol);
|
||||||
other.failedPaths.forEach(builder::failedPath);
|
other.failedPaths.forEach(builder::failedPath);
|
||||||
|
other.wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
other.wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
other.wikiCitations.forEach(builder::wikiCitation);
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,12 +94,61 @@ public record SourceEvidenceLedger(
|
|||||||
sourcePaths.forEach(builder::sourcePath);
|
sourcePaths.forEach(builder::sourcePath);
|
||||||
sourceSymbols.forEach(builder::symbol);
|
sourceSymbols.forEach(builder::symbol);
|
||||||
failedPaths.forEach(builder::failedPath);
|
failedPaths.forEach(builder::failedPath);
|
||||||
|
wikiPageTitles.forEach(builder::wikiPageTitle);
|
||||||
|
wikiChunkIds.forEach(builder::wikiChunkId);
|
||||||
|
wikiCitations.forEach(builder::wikiCitation);
|
||||||
builder.sourcePath(path);
|
builder.sourcePath(path);
|
||||||
return builder.build();
|
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() {
|
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) {
|
public boolean hasPath(String path) {
|
||||||
@ -103,6 +166,7 @@ public record SourceEvidenceLedger(
|
|||||||
}
|
}
|
||||||
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
|
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
|
||||||
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
|
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
|
||||||
|
|
||||||
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
|
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
|
||||||
while (fileMatcher.find()) {
|
while (fileMatcher.find()) {
|
||||||
String ref = fileMatcher.group();
|
String ref = fileMatcher.group();
|
||||||
@ -111,6 +175,7 @@ public record SourceEvidenceLedger(
|
|||||||
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
|
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
|
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
|
||||||
while (symbolMatcher.find()) {
|
while (symbolMatcher.find()) {
|
||||||
String ref = symbolMatcher.group();
|
String ref = symbolMatcher.group();
|
||||||
@ -118,9 +183,85 @@ public record SourceEvidenceLedger(
|
|||||||
unsupported.add(ref);
|
unsupported.add(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateWikiCitations(answer, unsupported);
|
||||||
|
|
||||||
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(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) {
|
private boolean hasFileName(String fileName) {
|
||||||
String normalized = normalizePath(fileName);
|
String normalized = normalizePath(fileName);
|
||||||
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
||||||
@ -158,6 +299,56 @@ public record SourceEvidenceLedger(
|
|||||||
recordSymbols(text, builder);
|
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) {
|
private static void recordSymbols(String text, Builder builder) {
|
||||||
Matcher matcher = DECLARED_TYPE.matcher(text);
|
Matcher matcher = DECLARED_TYPE.matcher(text);
|
||||||
while (matcher.find()) {
|
while (matcher.find()) {
|
||||||
@ -180,6 +371,9 @@ public record SourceEvidenceLedger(
|
|||||||
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
|
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
|
||||||
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
|
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
|
||||||
private final LinkedHashSet<String> failedPaths = 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) {
|
void sourcePath(String path) {
|
||||||
String normalized = normalizePath(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() {
|
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();
|
JSONArray arr = new JSONArray();
|
||||||
|
int index = 1;
|
||||||
for (HybridRetriever.ChunkHit hit : hits) {
|
for (HybridRetriever.ChunkHit hit : hits) {
|
||||||
cn.hutool.json.JSONObject obj = JSONUtil.createObj()
|
cn.hutool.json.JSONObject obj = JSONUtil.createObj()
|
||||||
|
.set("index", index)
|
||||||
.set("chunkId", hit.chunkId())
|
.set("chunkId", hit.chunkId())
|
||||||
.set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown"))
|
.set("rawTitle", rawTitles.getOrDefault(hit.rawId(), "unknown"))
|
||||||
.set("snippet", hit.snippet())
|
.set("snippet", hit.snippet())
|
||||||
.set("score", String.format("%.4f", hit.score()));
|
.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.pageNumber() != null) obj.set("pageNumber", hit.pageNumber());
|
||||||
if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) {
|
if (hit.headerBreadcrumb() != null && !hit.headerBreadcrumb().isBlank()) {
|
||||||
obj.set("section", hit.headerBreadcrumb());
|
obj.set("section", hit.headerBreadcrumb());
|
||||||
}
|
}
|
||||||
arr.add(obj);
|
arr.add(obj);
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSONUtil.createObj()
|
return JSONUtil.createObj()
|
||||||
@ -416,6 +417,7 @@ public class WikiTool {
|
|||||||
.set("query", query)
|
.set("query", query)
|
||||||
.set("matchCount", hits.size())
|
.set("matchCount", hits.size())
|
||||||
.set("chunks", arr)
|
.set("chunks", arr)
|
||||||
|
.set("citationHint", "引用格式示例:[1] 表示第一条结果,[2] 表示第二条结果。在回答末尾列出所有引用来源。")
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -149,4 +149,89 @@ class SourceEvidenceLedgerTest {
|
|||||||
assertFalse(validation.valid());
|
assertFalse(validation.valid());
|
||||||
assertTrue(validation.unsupportedReferences().contains("RandomMadeUpService"));
|
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