mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): reverse-citation extractor — bind synthesis pages to the exact source chunks they cite
This commit is contained in:
parent
e1759e0e3c
commit
a5b952050c
@ -0,0 +1,139 @@
|
||||
package vip.mate.wiki.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.wiki.model.WikiChunkEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Reverse-citation parser: scans the markdown output of a transformation run
|
||||
* for references that point back into the source raw material (e.g.
|
||||
* {@code 第 14 页}, {@code page 14}, {@code 示例题号 1, 5}, {@code 第 5 题})
|
||||
* and resolves each reference to a chunk in the source raw.
|
||||
*
|
||||
* <p>The resolved chunk IDs are passed to
|
||||
* {@link WikiCitationService#buildCitations(Long, Long, List)} so the
|
||||
* synthesis page cites only the specific chunks the LLM said it relied on
|
||||
* rather than every chunk of the source raw — this keeps the citation
|
||||
* graph (and the relation signals derived from shared citations) clean.
|
||||
*
|
||||
* <p>When no parseable references are found the extractor returns {@code 0}
|
||||
* without touching existing citations; the caller can decide whether to
|
||||
* fall back to the raw-level default citation build.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WikiTransformationCitationExtractor {
|
||||
|
||||
private final WikiChunkService chunkService;
|
||||
private final WikiCitationService citationService;
|
||||
|
||||
/** {@code 第 N 页} / {@code 页 N} / {@code page N} / {@code p.N} / {@code p N} */
|
||||
private static final Pattern PAGE_REF = Pattern.compile(
|
||||
"(?:第\\s*(\\d+)\\s*页|页\\s+(\\d+)|[Pp]age\\s+(\\d+)|p\\.\\s*(\\d+)|p\\s+(\\d+))");
|
||||
|
||||
/** {@code 第 N 题} / {@code 例(题)? N} / {@code 题 N} / {@code Problem N} / {@code Example N} */
|
||||
private static final Pattern PROBLEM_REF = Pattern.compile(
|
||||
"(?:第\\s*(\\d+)\\s*题|例(?:题)?\\s*(\\d+)|题\\s+(\\d+)|[Pp]roblem\\s+(\\d+)|[Ee]xample\\s+(\\d+))");
|
||||
|
||||
/**
|
||||
* Run extract → resolve → write. Returns the count of chunk citations
|
||||
* actually written. Best-effort: any internal exception is logged and
|
||||
* the call returns 0 so callers can fall back without surfacing the
|
||||
* error to the user.
|
||||
*/
|
||||
public int extractAndApply(Long pageId, Long kbId, Long sourceRawId, String output) {
|
||||
if (pageId == null || kbId == null || sourceRawId == null) return 0;
|
||||
if (output == null || output.isBlank()) return 0;
|
||||
try {
|
||||
Set<Integer> pageRefs = parseNumeric(output, PAGE_REF);
|
||||
Set<Integer> problemRefs = parseNumeric(output, PROBLEM_REF);
|
||||
if (pageRefs.isEmpty() && problemRefs.isEmpty()) return 0;
|
||||
|
||||
List<WikiChunkEntity> chunks = chunkService.listByRawId(sourceRawId);
|
||||
if (chunks.isEmpty()) return 0;
|
||||
|
||||
Set<Long> hitIds = new LinkedHashSet<>();
|
||||
for (WikiChunkEntity chunk : chunks) {
|
||||
if (chunkMatches(chunk, pageRefs, problemRefs)) {
|
||||
hitIds.add(chunk.getId());
|
||||
}
|
||||
}
|
||||
if (hitIds.isEmpty()) return 0;
|
||||
|
||||
citationService.buildCitations(pageId, kbId, new ArrayList<>(hitIds));
|
||||
log.info("[WikiCitationExtractor] page={} kb={} cited {} chunks "
|
||||
+ "(pageRefs={}, problemRefs={})",
|
||||
pageId, kbId, hitIds.size(), pageRefs, problemRefs);
|
||||
return hitIds.size();
|
||||
} catch (Exception e) {
|
||||
log.warn("[WikiCitationExtractor] extract failed pageId={}: {}", pageId, e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every integer captured by any group of the supplied pattern. */
|
||||
private static Set<Integer> parseNumeric(String text, Pattern pattern) {
|
||||
Set<Integer> out = new LinkedHashSet<>();
|
||||
Matcher m = pattern.matcher(text);
|
||||
while (m.find()) {
|
||||
for (int i = 1; i <= m.groupCount(); i++) {
|
||||
String g = m.group(i);
|
||||
if (g != null) {
|
||||
try { out.add(Integer.parseInt(g)); break; }
|
||||
catch (NumberFormatException ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk is a citation hit when:
|
||||
* <ul>
|
||||
* <li>its {@code pageNumber} matches one of the {@code pageRefs}, OR</li>
|
||||
* <li>its {@code content} contains a problem marker matching one of
|
||||
* {@code problemRefs} (e.g. "第 5 题" / "5." / "Problem 5").</li>
|
||||
* </ul>
|
||||
*/
|
||||
private boolean chunkMatches(WikiChunkEntity chunk, Set<Integer> pageRefs, Set<Integer> problemRefs) {
|
||||
if (!pageRefs.isEmpty()
|
||||
&& chunk.getPageNumber() != null
|
||||
&& pageRefs.contains(chunk.getPageNumber())) {
|
||||
return true;
|
||||
}
|
||||
if (!problemRefs.isEmpty() && chunk.getContent() != null) {
|
||||
String content = chunk.getContent();
|
||||
for (Integer n : problemRefs) {
|
||||
if (containsProblemMarker(content, n)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Match any of the conventional problem-number forms in the chunk content. */
|
||||
private static boolean containsProblemMarker(String content, int n) {
|
||||
if (content == null) return false;
|
||||
return content.contains("第 " + n + " 题")
|
||||
|| content.contains("第" + n + "题")
|
||||
|| content.contains("例 " + n)
|
||||
|| content.contains("例" + n)
|
||||
|| content.contains("题 " + n)
|
||||
|| content.contains("Problem " + n)
|
||||
|| content.contains("Example " + n)
|
||||
// Common "1.", "2." problem-number markers at line start. Cheap
|
||||
// contains-check rather than a regex anchor — the false-positive
|
||||
// rate is low because we only match when problemRefs is non-empty,
|
||||
// i.e. the LLM explicitly cited a numbered example.
|
||||
|| content.contains("\n" + n + ". ")
|
||||
|| content.startsWith(n + ". ");
|
||||
}
|
||||
}
|
||||
@ -64,6 +64,13 @@ public class WikiTransformationExecutor {
|
||||
@Autowired(required = false)
|
||||
private WikiEmbeddingService embeddingService;
|
||||
|
||||
/** Optional. When wired, the executor parses references like
|
||||
* {@code 第 5 题 / 第 14 页 / page 14} out of the output and writes
|
||||
* chunk-level citations binding the synthesis page back to the source
|
||||
* chunks the LLM said it relied on. */
|
||||
@Autowired(required = false)
|
||||
private WikiTransformationCitationExtractor citationExtractor;
|
||||
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
@ -498,6 +505,23 @@ public class WikiTransformationExecutor {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget reverse-citation extraction. If the LLM cited
|
||||
// specific page numbers / problem numbers, write precise chunk
|
||||
// citations binding the synthesis page back to those source chunks.
|
||||
if (citationExtractor != null) {
|
||||
final Long pid = persisted.getId();
|
||||
final Long kid = kbId;
|
||||
final Long rid = raw.getId();
|
||||
final String out = output;
|
||||
WORKER.submit(() -> {
|
||||
try { citationExtractor.extractAndApply(pid, kid, rid, out); }
|
||||
catch (Exception ee) {
|
||||
log.warn("[WikiTransformation] post-save citation extract failed pageId={}: {}",
|
||||
pid, ee.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user