+ * Strategy is intentionally lightweight: + *
+ * RFC-051 PR-1c: {@code pageNumber} and {@code headerBreadcrumb} are + * populated when the chunk has those columns set (lazy ingest with + * preprocessor on, or backfilled chunks). Both are nullable. */ - public record ChunkHit(Long chunkId, Long rawId, String snippet, float score) {} + public record ChunkHit(Long chunkId, Long rawId, String snippet, float score, + Integer pageNumber, String headerBreadcrumb) { + + /** Backwards-compatible factory for callers that don't yet pass metadata. */ + public ChunkHit(Long chunkId, Long rawId, String snippet, float score) { + this(chunkId, rawId, snippet, score, null, null); + } + } /** * RFC-032: Enhanced search returning PageSearchResult with snippet and matchedBy metadata. @@ -152,7 +163,8 @@ public class HybridRetriever { String snippet = c.getContent().length() > 300 ? c.getContent().substring(0, 300) + "..." : c.getContent(); - return new ChunkHit(c.getId(), c.getRawId(), snippet, score); + return new ChunkHit(c.getId(), c.getRawId(), snippet, score, + c.getPageNumber(), c.getHeaderBreadcrumb()); }) .sorted(Comparator.comparingDouble(ChunkHit::score).reversed()) .limit(topK) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java new file mode 100644 index 00000000..7b9275bd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java @@ -0,0 +1,104 @@ +package vip.mate.wiki.service; + +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Component; + +/** + * RFC-051 PR-1c: source-type-specific text normalization. + *
+ * Goal is structural cleanup before chunking — strip noise from HTML, + * normalize line endings, trim duplicate whitespace — without touching the + * actual semantic content. Heading and page-marker extraction lives in + * {@link DocumentPreprocessService} because it needs the offsets of the + * surviving text, not just the text itself. + * + *
Tika is intentionally not pulled in: existing {@code DocumentExtractTool}
+ * already covers PDF/Office via pdftotext / pdfplumber / Java fallbacks.
+ */
+@Slf4j
+@Component
+public class WikiContentNormalizer {
+
+ private static final int MAX_HTML_LEN = 8 * 1024 * 1024; // 8 MB safety cap
+
+ /**
+ * Normalize raw text by source type. Returns the input unchanged when the
+ * source type is unknown so we never lose content silently.
+ *
+ * @param sourceType {@code WikiRawMaterialEntity.sourceType} value
+ * (text / pdf / docx / xlsx / pptx / url / paste / markdown)
+ * @param rawText extracted text content
+ * @return cleaned text, never {@code null}
+ */
+ public String normalize(String sourceType, String rawText) {
+ if (rawText == null) return "";
+ String type = sourceType == null ? "" : sourceType.toLowerCase();
+ return switch (type) {
+ case "url", "html" -> normalizeHtml(rawText);
+ // PDF text from DocumentExtractTool may already contain "--- Page N ---"
+ // markers; we keep them so the preprocessor can map char offsets to pages.
+ case "pdf" -> collapseBlankLines(rawText);
+ case "docx", "pptx", "xlsx" -> collapseBlankLines(rawText);
+ case "markdown", "md", "text", "paste" -> collapseBlankLines(rawText);
+ default -> collapseBlankLines(rawText);
+ };
+ }
+
+ /**
+ * Strip nav/footer/script/style/aside and ad-like classes from HTML, then
+ * return readable text. Falls through to the raw input when the document
+ * is too large to parse safely or jsoup throws.
+ */
+ private String normalizeHtml(String rawHtml) {
+ if (rawHtml.length() > MAX_HTML_LEN) {
+ log.warn("[WikiContentNormalizer] HTML payload exceeds {} bytes, skipping cleanup", MAX_HTML_LEN);
+ return collapseBlankLines(rawHtml);
+ }
+ try {
+ Document doc = Jsoup.parse(rawHtml);
+ // Drop structural noise.
+ doc.select("script, style, noscript, nav, header, footer, aside, form, iframe").remove();
+ // Drop common ad / share / cookie banners by class hint.
+ Elements adNodes = doc.select(
+ "[class*=ad-], [class*=ads], [class^=ad_], [id*=ads], " +
+ "[class*=cookie-banner], [class*=share-], [class*=related-posts]");
+ adNodes.remove();
+ // Strip aria-hidden / display:none nodes — these are usually skip links / overlays.
+ for (Element hidden : doc.select("[aria-hidden=true], [hidden]")) hidden.remove();
+
+ // Convert to text. Jsoup .text() collapses whitespace; we want headings on
+ // their own lines so the preprocessor can detect them. Walk children manually
+ // to preserve heading boundaries.
+ StringBuilder sb = new StringBuilder(Math.min(rawHtml.length(), 256 * 1024));
+ for (Element el : doc.body() != null ? doc.body().getAllElements() : doc.getAllElements()) {
+ String tag = el.tagName();
+ String text = el.ownText();
+ if (text.isBlank()) continue;
+ if (tag.matches("h[1-6]")) {
+ int level = Integer.parseInt(tag.substring(1));
+ sb.append('\n').append("#".repeat(level)).append(' ').append(text.trim()).append('\n');
+ } else {
+ sb.append(text.trim()).append('\n');
+ }
+ }
+ String out = sb.toString();
+ return out.isBlank() ? collapseBlankLines(rawHtml) : collapseBlankLines(out);
+ } catch (Exception e) {
+ log.warn("[WikiContentNormalizer] HTML parse failed, falling back to raw text: {}", e.getMessage());
+ return collapseBlankLines(rawHtml);
+ }
+ }
+
+ /**
+ * Collapse 3+ consecutive blank lines down to 2, normalize CRLF to LF.
+ * Cheap, lossless cleanup that helps chunk boundary detection.
+ */
+ private String collapseBlankLines(String text) {
+ String unified = text.replace("\r\n", "\n").replace('\r', '\n');
+ return unified.replaceAll("\n{3,}", "\n\n");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
index a691c01f..f2926808 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java
@@ -16,6 +16,7 @@ import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.WikiProperties;
+import vip.mate.wiki.dto.WikiChunkDraft;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
@@ -60,6 +61,15 @@ public class WikiProcessingService {
@org.springframework.context.annotation.Lazy
private vip.mate.wiki.job.WikiProcessingJobService wikiJobService;
+ /**
+ * RFC-051 PR-1c: optional preprocessor that fills chunk metadata
+ * (page_number / token_count / header_breadcrumb / source_section).
+ * Marked optional so unit tests that construct this service directly
+ * (without Spring) can opt out without exploding.
+ */
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ private DocumentPreprocessService preprocessService;
+
/** Parallel chunk / material processing executor (JDK 21 virtual threads) */
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
@@ -1650,6 +1660,34 @@ public class WikiProcessingService {
}
}
+ /**
+ * RFC-051 PR-1c: bridge from {@link DocumentPreprocessService.Chunker} to
+ * the existing sentence-boundary chunker. Returns {@code [start, end]}
+ * pairs over the supplied text.
+ */
+ private List
@@ -1677,12 +1715,21 @@ public class WikiProcessingService {
return;
}
- List