mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(wiki): dedup pages by title and bound route prompt growth (#321)
This commit is contained in:
parent
c1691e466c
commit
48024e4a83
@ -56,6 +56,29 @@ public class WikiProperties {
|
||||
/** 注入 agent prompt 的最大字符数 */
|
||||
private int maxContextChars = 10000;
|
||||
|
||||
/**
|
||||
* Hard cap on the existing-pages index injected into the route / batch-create
|
||||
* prompts. The index lists every non-archived page in the KB so the router can
|
||||
* decide create-vs-update and emit cross-links. Without a cap it grows linearly
|
||||
* with the KB and eventually overflows the model context window
|
||||
* ("Prompt exceeds max length"). When the rendered index exceeds this many
|
||||
* characters the listing stops and a trailing marker records how many pages
|
||||
* were omitted. Title-based dedup at persist time (findByCanonicalTitle) keeps
|
||||
* truncation safe: a page the router can no longer see is converted from
|
||||
* create to update on save rather than duplicated. Set to 0 to disable the
|
||||
* char cap.
|
||||
*/
|
||||
private int existingPagesIndexMaxChars = 12000;
|
||||
|
||||
/**
|
||||
* Hard cap on the number of pages listed in the existing-pages index, applied
|
||||
* together with {@link #existingPagesIndexMaxChars} — whichever limit is hit
|
||||
* first stops the listing. Manually-edited pages are listed first so
|
||||
* user-curated entries are never the ones dropped. Set to 0 to disable the
|
||||
* page-count cap.
|
||||
*/
|
||||
private int existingPagesIndexMaxPages = 200;
|
||||
|
||||
/** 单个原始材料最多生成的 Wiki 页面数 */
|
||||
private int maxPagesPerRaw = 15;
|
||||
|
||||
|
||||
@ -9,9 +9,11 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.wiki.job.WikiChunkTokenBackfillJob;
|
||||
import vip.mate.wiki.service.WikiOverviewService;
|
||||
import vip.mate.wiki.service.WikiPageService;
|
||||
import vip.mate.wiki.service.WikiScaffoldService;
|
||||
|
||||
import java.util.HashMap;
|
||||
@ -34,6 +36,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
public class WikiAdminController {
|
||||
|
||||
private final WikiScaffoldService scaffoldService;
|
||||
private final WikiPageService pageService;
|
||||
|
||||
/** Optional so the controller can boot in environments where the rebuilder isn't wired (e.g. minimal tests). */
|
||||
@Autowired(required = false)
|
||||
@ -81,4 +84,20 @@ public class WikiAdminController {
|
||||
body.put("filledThisBatch", Math.max(0, beforePending - afterPending));
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@Operation(summary = "Merge duplicate pages that share a canonical title",
|
||||
description = "Heals duplicate rows produced before title-based dedup existed (one concept "
|
||||
+ "stored under several LLM-minted slugs). Defaults to a dry run that only reports "
|
||||
+ "what would change. Set dryRun=false to apply. concatenate=true (default) appends each "
|
||||
+ "loser's body to the winner so no content is lost; concatenate=false keeps only the "
|
||||
+ "winner's body. Protected (system/locked) pages always win and are never deleted.")
|
||||
@PostMapping("/kb/{kbId}/merge-duplicate-titles")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public ResponseEntity<Map<String, Object>> mergeDuplicateTitles(
|
||||
@PathVariable Long kbId,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "true") boolean concatenate) {
|
||||
Map<String, Object> report = pageService.mergeDuplicateTitles(kbId, dryRun, concatenate);
|
||||
return ResponseEntity.ok(report);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,12 @@ import vip.mate.wiki.repository.WikiRelationMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
@ -297,6 +300,51 @@ public class WikiPageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a title into its canonical identity form, mirroring how a
|
||||
* wikilink resolver matches note names: lowercase, then drop every
|
||||
* whitespace / hyphen / underscore (including the full-width space) so
|
||||
* {@code "二味拔毒散"}, {@code "二味拔毒散 "} and {@code "二味-拔毒散"} all
|
||||
* collapse to the same key.
|
||||
* <p>
|
||||
* Title is the stable, human-meaningful identity of a concept. The slug, by
|
||||
* contrast, is LLM-generated and drifts across runs and romanizations
|
||||
* ({@code erwei-badu-san} / {@code er-wei-badu-san} / an English translation),
|
||||
* which is why slug-only matching leaks duplicate rows for one concept. Title
|
||||
* matching is the primary dedup key; {@link #canonicalSlug(String)} stays as a
|
||||
* secondary cross-spelling fallback.
|
||||
*/
|
||||
public static String canonicalTitle(String title) {
|
||||
if (title == null) return "";
|
||||
String lowered = title.trim().toLowerCase();
|
||||
StringBuilder sb = new StringBuilder(lowered.length());
|
||||
for (int i = 0; i < lowered.length(); i++) {
|
||||
char c = lowered.charAt(i);
|
||||
if (c == '-' || c == '_' || c == ' ' || Character.isWhitespace(c)) {
|
||||
continue;
|
||||
}
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing page in the KB whose title canonically matches the given
|
||||
* title. Reuses the {@link #listSummaries(Long)} cache (which carries title),
|
||||
* then loads the full entity for the match. Returns the first canonical-title
|
||||
* match, or {@code null} when none exists.
|
||||
*/
|
||||
public WikiPageEntity findByCanonicalTitle(Long kbId, String title) {
|
||||
String canonical = canonicalTitle(title);
|
||||
if (canonical.isEmpty()) return null;
|
||||
for (WikiPageEntity p : listSummaries(kbId)) {
|
||||
if (canonicalTitle(p.getTitle()).equals(canonical)) {
|
||||
return getBySlug(kbId, p.getSlug());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public WikiPageEntity getById(Long id) {
|
||||
return pageMapper.selectById(id);
|
||||
}
|
||||
@ -917,6 +965,192 @@ public class WikiPageService {
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Winner selection within a duplicate-title group: keep the page that
|
||||
* carries the most information. Prefer the longest content, then the
|
||||
* highest version (most merged), then the smallest id (earliest-created,
|
||||
* for a stable deterministic result).
|
||||
*/
|
||||
private static final Comparator<WikiPageEntity> MERGE_WINNER_ORDER =
|
||||
Comparator.comparingInt((WikiPageEntity p) -> p.getContent() == null ? 0 : p.getContent().length())
|
||||
.thenComparingInt(p -> p.getVersion() == null ? 0 : p.getVersion())
|
||||
.thenComparing(WikiPageEntity::getId, Comparator.reverseOrder());
|
||||
|
||||
/**
|
||||
* One-time maintenance: collapse pages that share a canonical title (see
|
||||
* {@link #canonicalTitle(String)}) into a single page, healing the duplicate
|
||||
* rows produced before title-based dedup existed (an LLM-minted slug drifts
|
||||
* across runs, so one concept landed as many rows under different slugs).
|
||||
* <p>
|
||||
* For each group of duplicates a winner is chosen ({@link #MERGE_WINNER_ORDER});
|
||||
* every loser's inbound {@code [[loserSlug]]} reference is redirected to the
|
||||
* winner, the losers' source lineage is folded into the winner, their bodies
|
||||
* are optionally appended (so no content is lost), and the loser rows are
|
||||
* deleted. A protected page (system / locked) always wins and is never
|
||||
* deleted; a group with more than one protected page is skipped for manual
|
||||
* resolution.
|
||||
*
|
||||
* @param kbId knowledge base to clean
|
||||
* @param dryRun when {@code true}, only report what would change — no writes
|
||||
* @param concatenateContent when {@code true}, append each loser's body to the
|
||||
* winner under a separator; when {@code false}, keep
|
||||
* only the winner's body (loser bodies are discarded)
|
||||
* @return a structured report (counts + per-group winner/loser slugs)
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> mergeDuplicateTitles(Long kbId, boolean dryRun, boolean concatenateContent) {
|
||||
List<WikiPageEntity> all = listByKbIdWithContent(kbId);
|
||||
|
||||
// Group by canonical title, preserving first-seen order for a stable report.
|
||||
Map<String, List<WikiPageEntity>> groups = new LinkedHashMap<>();
|
||||
for (WikiPageEntity p : all) {
|
||||
String ct = canonicalTitle(p.getTitle());
|
||||
if (ct.isEmpty()) continue;
|
||||
groups.computeIfAbsent(ct, k -> new ArrayList<>()).add(p);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> groupReports = new ArrayList<>();
|
||||
int duplicateGroups = 0;
|
||||
int pagesRemoved = 0;
|
||||
|
||||
for (Map.Entry<String, List<WikiPageEntity>> entry : groups.entrySet()) {
|
||||
List<WikiPageEntity> grp = entry.getValue();
|
||||
if (grp.size() < 2) continue;
|
||||
|
||||
List<WikiPageEntity> protectedPages = grp.stream().filter(WikiPageService::isProtected).toList();
|
||||
if (protectedPages.size() > 1) {
|
||||
Map<String, Object> skip = new LinkedHashMap<>();
|
||||
skip.put("canonicalTitle", entry.getKey());
|
||||
skip.put("title", grp.get(0).getTitle());
|
||||
skip.put("skipped", "multiple protected pages; resolve manually");
|
||||
skip.put("slugs", grp.stream().map(WikiPageEntity::getSlug).toList());
|
||||
groupReports.add(skip);
|
||||
continue;
|
||||
}
|
||||
|
||||
WikiPageEntity winner = protectedPages.size() == 1
|
||||
? protectedPages.get(0)
|
||||
: grp.stream().max(MERGE_WINNER_ORDER).orElseThrow();
|
||||
List<WikiPageEntity> losers = grp.stream()
|
||||
.filter(p -> !p.getId().equals(winner.getId()))
|
||||
.filter(p -> !isProtected(p))
|
||||
.toList();
|
||||
if (losers.isEmpty()) continue;
|
||||
|
||||
duplicateGroups++;
|
||||
pagesRemoved += losers.size();
|
||||
|
||||
Map<String, Object> gr = new LinkedHashMap<>();
|
||||
gr.put("canonicalTitle", entry.getKey());
|
||||
gr.put("title", winner.getTitle());
|
||||
gr.put("winnerSlug", winner.getSlug());
|
||||
gr.put("winnerVersion", winner.getVersion());
|
||||
gr.put("loserSlugs", losers.stream().map(WikiPageEntity::getSlug).toList());
|
||||
groupReports.add(gr);
|
||||
|
||||
if (!dryRun) {
|
||||
mergeGroupInto(kbId, winner, losers, concatenateContent && !isProtected(winner));
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && duplicateGroups > 0) {
|
||||
evictSummaryCache(kbId);
|
||||
if (auditEventService != null) {
|
||||
try {
|
||||
String detail = objectMapper.writeValueAsString(Map.of(
|
||||
"kbId", kbId,
|
||||
"duplicateGroups", duplicateGroups,
|
||||
"pagesRemoved", pagesRemoved,
|
||||
"concatenateContent", concatenateContent));
|
||||
auditEventService.record("wiki.page.merge-duplicates", "wiki_kb",
|
||||
String.valueOf(kbId), "merge duplicate titles", detail);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Wiki] Audit emit failed for merge-duplicates kbId={}: {}", kbId, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("kbId", kbId);
|
||||
report.put("dryRun", dryRun);
|
||||
report.put("concatenateContent", concatenateContent);
|
||||
report.put("totalPages", all.size());
|
||||
report.put("duplicateGroups", duplicateGroups);
|
||||
report.put("pagesRemoved", dryRun ? 0 : pagesRemoved);
|
||||
report.put("pagesWouldRemove", pagesRemoved);
|
||||
report.put("groups", groupReports);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold {@code losers} into {@code winner}: redirect inbound links, merge
|
||||
* source lineage, optionally append bodies, then delete the loser rows.
|
||||
*/
|
||||
private void mergeGroupInto(Long kbId, WikiPageEntity winner,
|
||||
List<WikiPageEntity> losers, boolean concatenate) {
|
||||
String winnerSlug = winner.getSlug();
|
||||
|
||||
for (WikiPageEntity loser : losers) {
|
||||
// Redirect every [[loserSlug]] reference (in any page, including the
|
||||
// winner) to the winner before the loser row goes away, so no link
|
||||
// is demoted to plain text.
|
||||
try {
|
||||
cascadeRenameReferrers(kbId, loser.getId(), loser.getSlug(), winnerSlug);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[Wiki] merge: redirect referrers {}→{} failed (continuing): {}",
|
||||
loser.getSlug(), winnerSlug, ex.toString());
|
||||
}
|
||||
// Fold the loser's source provenance into the winner.
|
||||
for (SourceEntry se : parseSourceEntries(loser.getSourceEntries())) {
|
||||
mergeSourceLineage(winner.getId(), se.rawId(), se.rawTitle());
|
||||
}
|
||||
for (Long rid : parseSourceRawIds(loser.getSourceRawIds())) {
|
||||
mergeSourceLineage(winner.getId(), rid, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (concatenate) {
|
||||
// Re-load to pick up the lineage updates just written.
|
||||
WikiPageEntity fresh = pageMapper.selectById(winner.getId());
|
||||
if (fresh != null) {
|
||||
StringBuilder merged = new StringBuilder(fresh.getContent() != null ? fresh.getContent() : "");
|
||||
for (WikiPageEntity loser : losers) {
|
||||
String body = loser.getContent();
|
||||
if (body == null || body.isBlank()) continue;
|
||||
// Repoint the loser's own self-links so the appended text
|
||||
// targets the winner rather than the soon-deleted slug.
|
||||
body = linkService.renameLink(body, loser.getSlug(), winnerSlug);
|
||||
merged.append("\n\n---\n\n")
|
||||
.append("> Merged from duplicate page `").append(loser.getSlug()).append("`");
|
||||
if (loser.getTitle() != null && !loser.getTitle().isBlank()) {
|
||||
merged.append(" (").append(loser.getTitle()).append(")");
|
||||
}
|
||||
merged.append("\n\n").append(body);
|
||||
}
|
||||
fresh.setContent(merged.toString());
|
||||
fresh.setVersion((fresh.getVersion() == null ? 1 : fresh.getVersion()) + 1);
|
||||
fresh.setUpdateTime(LocalDateTime.now());
|
||||
applyLinkAnalysis(fresh);
|
||||
pageMapper.updateById(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
for (WikiPageEntity loser : losers) {
|
||||
if (relationMapper != null) {
|
||||
try {
|
||||
relationMapper.delete(new LambdaQueryWrapper<WikiRelationEntity>()
|
||||
.eq(WikiRelationEntity::getKbId, kbId)
|
||||
.and(w -> w.eq(WikiRelationEntity::getPageAId, loser.getId())
|
||||
.or().eq(WikiRelationEntity::getPageBId, loser.getId())));
|
||||
} catch (RuntimeException ignore) {
|
||||
// relation table is a reserved cache; cleanup is best-effort
|
||||
}
|
||||
}
|
||||
pageMapper.deleteById(loser.getId());
|
||||
}
|
||||
evictSummaryCache(kbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-051 PR-7: flip the {@code archived} flag.
|
||||
* <p>
|
||||
|
||||
@ -168,6 +168,17 @@ public class WikiProcessingService {
|
||||
* slug 注册为 winner,后到的 chunk 看到 winner 后会把内容写入 winner 对应的 page。
|
||||
*/
|
||||
final ConcurrentHashMap<String, String> slugClaims = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* Per-run title claim table: canonical title → the actual slug of the first
|
||||
* page that claimed that concept this run.
|
||||
* <p>
|
||||
* Title is the stable concept identity (the slug is LLM-generated and drifts),
|
||||
* so this closes the parallel-create race that {@link #slugClaims} cannot:
|
||||
* two phase-B pages with the same title but different slugs would both miss
|
||||
* the DB lookup and insert two rows. The first to {@link ConcurrentHashMap#computeIfAbsent}
|
||||
* wins; later creates redirect their content into the winner page.
|
||||
*/
|
||||
final ConcurrentHashMap<String, String> titleClaims = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* Per-run merge dedup set: slugs that have already been successfully merged during
|
||||
* this raw material processing run. Prevents the same page from being merged N times
|
||||
@ -1540,6 +1551,22 @@ public class WikiProcessingService {
|
||||
// point at a tombstoned row.
|
||||
if (isAborted(rawId, "savePageContent slug=" + slug)) return false;
|
||||
|
||||
// Fallback 0a: canonical-title match — title is the stable concept identity.
|
||||
// The LLM-generated slug drifts across runs and romanizations, so the same
|
||||
// concept otherwise lands as many rows under different slugs (the duplicate
|
||||
// explosion this guards against). If a page with the same canonical title
|
||||
// already exists under a different slug, merge into it instead of creating.
|
||||
WikiPageEntity existingByTitle = pageService.findByCanonicalTitle(kbId, title);
|
||||
if (existingByTitle != null && !existingByTitle.getSlug().equals(slug)) {
|
||||
String actualSlug = existingByTitle.getSlug();
|
||||
pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId);
|
||||
pageService.mergeSourceLineage(existingByTitle.getId(), rawId, raw.getTitle());
|
||||
afterPagePersisted(existingByTitle.getId(), kbId, pageType, metadataNode, dependsOnNode, true);
|
||||
log.info("[Wiki] Phase B create slug='{}' title='{}' canonical-title-matches existing '{}', updated",
|
||||
slug, title, actualSlug);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback 0: cross-spelling canonical match (DB has same concept under different slug)
|
||||
WikiPageEntity existingByCanonical = pageService.findByCanonicalSlug(kbId, slug);
|
||||
if (existingByCanonical != null && !existingByCanonical.getSlug().equals(slug)) {
|
||||
@ -1552,8 +1579,34 @@ public class WikiProcessingService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback 0.5: in-flight slug-claim arbitration across parallel chunks
|
||||
// Fallback 0.25: in-flight title-claim arbitration across parallel pages.
|
||||
// Closes the race that findByCanonicalTitle cannot: two parallel phase-B
|
||||
// creates with the same title but different slugs can both miss the DB
|
||||
// lookup (the row isn't committed yet) and insert two rows — title has no
|
||||
// DB unique constraint, so DuplicateKey won't catch it. The first to claim
|
||||
// the canonical title wins; losers redirect their content into the winner.
|
||||
ProgressCounter pcLocal = progressCounters.get(rawId);
|
||||
String canonicalTitle = WikiPageService.canonicalTitle(title);
|
||||
if (pcLocal != null && !canonicalTitle.isEmpty()) {
|
||||
final String claimingSlug = slug;
|
||||
String winnerSlug = pcLocal.titleClaims.computeIfAbsent(canonicalTitle, k -> claimingSlug);
|
||||
if (!winnerSlug.equals(slug)) {
|
||||
WikiPageEntity winner = pageService.getBySlug(kbId, winnerSlug);
|
||||
if (winner != null) {
|
||||
pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId);
|
||||
pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle());
|
||||
afterPagePersisted(winner.getId(), kbId, pageType, metadataNode, dependsOnNode, true);
|
||||
log.info("[Wiki] Phase B create slug='{}' title='{}' lost title-claim race to '{}', updated",
|
||||
slug, title, winnerSlug);
|
||||
return false;
|
||||
}
|
||||
log.info("[Wiki] Phase B create slug='{}' redirects to in-flight title winner '{}'",
|
||||
slug, winnerSlug);
|
||||
slug = winnerSlug;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 0.5: in-flight slug-claim arbitration across parallel chunks
|
||||
String canonical = WikiPageService.canonicalSlug(slug);
|
||||
if (pcLocal != null && !canonical.isEmpty()) {
|
||||
final String routedSlug = slug;
|
||||
@ -2048,20 +2101,59 @@ public class WikiProcessingService {
|
||||
return "(暂无已有页面)";
|
||||
}
|
||||
|
||||
// The index is injected verbatim into the route / batch-create prompts, so
|
||||
// it must stay bounded — otherwise it grows linearly with the KB and
|
||||
// overflows the model context window. Cap by both page count and chars;
|
||||
// truncation is safe because savePageContent dedups by canonical title at
|
||||
// persist time, so an omitted page is merged-on-save rather than duplicated.
|
||||
int maxChars = Math.max(0, properties.getExistingPagesIndexMaxChars());
|
||||
int maxPages = Math.max(0, properties.getExistingPagesIndexMaxPages());
|
||||
|
||||
// List manually-edited pages first so user-curated entries are never the
|
||||
// ones dropped when a cap is hit. Title order within each group is
|
||||
// preserved (listSummaries already sorts by title).
|
||||
List<WikiPageEntity> ordered = new ArrayList<>(summaries.size());
|
||||
for (WikiPageEntity p : summaries) {
|
||||
if ("manual".equals(p.getLastUpdatedBy())) ordered.add(p);
|
||||
}
|
||||
for (WikiPageEntity p : summaries) {
|
||||
if (!"manual".equals(p.getLastUpdatedBy())) ordered.add(p);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (WikiPageEntity page : summaries) {
|
||||
sb.append("- [[").append(page.getSlug()).append("]]");
|
||||
int listed = 0;
|
||||
for (WikiPageEntity page : ordered) {
|
||||
StringBuilder row = new StringBuilder();
|
||||
row.append("- [[").append(page.getSlug()).append("]]");
|
||||
if (page.getTitle() != null && !page.getTitle().isBlank()) {
|
||||
sb.append(" — ").append(page.getTitle());
|
||||
row.append(" — ").append(page.getTitle());
|
||||
}
|
||||
if ("manual".equals(page.getLastUpdatedBy())) {
|
||||
sb.append(" (手动编辑)");
|
||||
row.append(" (手动编辑)");
|
||||
}
|
||||
String summary = page.getSummary();
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
sb.append(" — ").append(summary);
|
||||
row.append(" — ").append(summary);
|
||||
}
|
||||
sb.append("\n");
|
||||
row.append("\n");
|
||||
|
||||
// Stop before exceeding either cap, but always emit at least one row.
|
||||
boolean overPageCap = maxPages > 0 && listed >= maxPages;
|
||||
boolean overCharCap = maxChars > 0 && listed > 0 && sb.length() + row.length() > maxChars;
|
||||
if (overPageCap || overCharCap) {
|
||||
break;
|
||||
}
|
||||
sb.append(row);
|
||||
listed++;
|
||||
}
|
||||
|
||||
int omitted = ordered.size() - listed;
|
||||
if (omitted > 0) {
|
||||
sb.append("- …(已省略 ").append(omitted)
|
||||
.append(" 个页面:已有页面过多,索引已截断。若材料涉及未列出的概念,按新建处理即可,")
|
||||
.append("系统会在落库时按标题自动归并到既有页面)\n");
|
||||
log.info("[Wiki] existing-pages index truncated for kbId={}: listed={} omitted={} chars={}",
|
||||
kbId, listed, omitted, sb.length());
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
package vip.mate.wiki.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
|
||||
import vip.mate.wiki.repository.WikiPageMapper;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* E2E coverage for {@link WikiPageService#mergeDuplicateTitles} — the one-time
|
||||
* maintenance op that collapses pages sharing a canonical title (the duplicate
|
||||
* rows produced before title-based dedup existed, when one concept landed under
|
||||
* several LLM-minted slugs).
|
||||
*
|
||||
* <p>Boots the full Spring + H2 + Flyway context so MyBatis-Plus's lambda cache
|
||||
* and the real cascade/link machinery are exercised end to end.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = {
|
||||
"spring.flyway.enabled=true",
|
||||
"spring.flyway.locations=classpath:db/migration/h2",
|
||||
"mateclaw.feature-flag.refresh-ms=999999"
|
||||
}
|
||||
)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
class WikiMergeDuplicateTitlesE2ETest {
|
||||
|
||||
@Autowired private WikiPageService pageService;
|
||||
@Autowired private WikiKnowledgeBaseService kbService;
|
||||
@Autowired private WikiPageMapper pageMapper;
|
||||
@Autowired private WikiKnowledgeBaseMapper kbMapper;
|
||||
|
||||
private Long kbId;
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
if (kbId != null) {
|
||||
pageMapper.delete(new LambdaQueryWrapper<WikiPageEntity>().eq(WikiPageEntity::getKbId, kbId));
|
||||
kbMapper.deleteById(kbId);
|
||||
pageService.evictSummaryCache(kbId);
|
||||
kbId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void seedKb() {
|
||||
WikiKnowledgeBaseEntity kb = kbService.create("merge-dup-" + System.nanoTime(), "merge test", null);
|
||||
kbId = kb.getId();
|
||||
pageMapper.delete(new LambdaQueryWrapper<WikiPageEntity>().eq(WikiPageEntity::getKbId, kbId));
|
||||
pageService.evictSummaryCache(kbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Three pages share the canonical title "医宗金鉴" under different slugs (the
|
||||
* exact failure mode from the bug report), plus a referrer linking to a loser.
|
||||
*/
|
||||
private void seedDuplicates() {
|
||||
// Winner: longest content.
|
||||
pageService.createPage(kbId, "yizong-jinjian", "医宗金鉴",
|
||||
"## 医宗金鉴\n\nThis is the most complete body with the richest detail aaa bbb ccc ddd.",
|
||||
"complete summary", "[]");
|
||||
// Losers: same canonical title, shorter content, different slugs.
|
||||
pageService.createPage(kbId, "yizong-jinjian-quanshu", "医宗金鉴",
|
||||
"Body B shorter.", "b summary", "[]");
|
||||
// Trailing-space title still canonicalizes equal.
|
||||
pageService.createPage(kbId, "yzjj", "医宗金鉴 ",
|
||||
"Body C tiny.", "c summary", "[]");
|
||||
// A referrer pointing at one of the losers.
|
||||
pageService.createPage(kbId, "ref", "Ref",
|
||||
"See [[yizong-jinjian-quanshu]] for the canonical text.",
|
||||
"referrer summary", "[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dry run reports duplicates without mutating anything")
|
||||
void dryRunReportsButDoesNotMutate() {
|
||||
seedKb();
|
||||
seedDuplicates();
|
||||
|
||||
Map<String, Object> report = pageService.mergeDuplicateTitles(kbId, true, true);
|
||||
|
||||
assertThat(report.get("dryRun")).isEqualTo(true);
|
||||
assertThat(report.get("duplicateGroups")).isEqualTo(1);
|
||||
assertThat(report.get("pagesWouldRemove")).isEqualTo(2);
|
||||
assertThat(report.get("pagesRemoved")).isEqualTo(0);
|
||||
|
||||
// Nothing deleted: all four pages still present.
|
||||
assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(4);
|
||||
assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNotNull();
|
||||
assertThat(pageService.getBySlug(kbId, "yzjj")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply with concatenate merges losers into winner, redirects refs, deletes losers")
|
||||
void applyConcatenateCollapsesGroup() {
|
||||
seedKb();
|
||||
seedDuplicates();
|
||||
|
||||
Map<String, Object> report = pageService.mergeDuplicateTitles(kbId, false, true);
|
||||
assertThat(report.get("duplicateGroups")).isEqualTo(1);
|
||||
assertThat(report.get("pagesRemoved")).isEqualTo(2);
|
||||
|
||||
// Only the winner + the referrer survive.
|
||||
assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2);
|
||||
assertThat(pageService.getBySlug(kbId, "yizong-jinjian-quanshu")).isNull();
|
||||
assertThat(pageService.getBySlug(kbId, "yzjj")).isNull();
|
||||
|
||||
// Winner keeps its body and gains the losers' bodies (no content lost).
|
||||
WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian");
|
||||
assertThat(winner).isNotNull();
|
||||
assertThat(winner.getContent())
|
||||
.contains("most complete body")
|
||||
.contains("Body B shorter.")
|
||||
.contains("Body C tiny.");
|
||||
assertThat(winner.getVersion()).isGreaterThan(1);
|
||||
|
||||
// Referrer's [[loserSlug]] is redirected to the winner, not demoted.
|
||||
WikiPageEntity ref = pageService.getBySlug(kbId, "ref");
|
||||
assertThat(ref.getContent())
|
||||
.doesNotContain("[[yizong-jinjian-quanshu]]")
|
||||
.contains("[[yizong-jinjian]]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply without concatenate keeps only the winner's body")
|
||||
void applyWithoutConcatenateDiscardsLoserBodies() {
|
||||
seedKb();
|
||||
seedDuplicates();
|
||||
|
||||
pageService.mergeDuplicateTitles(kbId, false, false);
|
||||
|
||||
assertThat(pageService.listByKbIdWithContent(kbId)).hasSize(2);
|
||||
WikiPageEntity winner = pageService.getBySlug(kbId, "yizong-jinjian");
|
||||
assertThat(winner).isNotNull();
|
||||
assertThat(winner.getContent())
|
||||
.contains("most complete body")
|
||||
.doesNotContain("Body B shorter.")
|
||||
.doesNotContain("Body C tiny.");
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,19 @@
|
||||
package vip.mate.wiki.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.repository.WikiPageMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@ -15,6 +22,15 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class WikiPageServiceTest {
|
||||
|
||||
static {
|
||||
// LambdaQueryWrapper resolves column metadata from MyBatis-Plus's TableInfo
|
||||
// cache, which Spring normally populates at startup. In a plain unit test we
|
||||
// seed it once so getBySlug / listSummaries can build their lambda queries.
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WikiPageEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualUpdateRefreshesUpdateTimeBeforePersisting() {
|
||||
WikiPageMapper mapper = mock(WikiPageMapper.class);
|
||||
@ -39,4 +55,61 @@ class WikiPageServiceTest {
|
||||
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));
|
||||
verify(mapper).updateById(page);
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalTitleFoldsCaseAndSeparators() {
|
||||
// Case + ASCII separators fold away so spelling variants collapse to one key.
|
||||
assertEquals("erweibadusan", WikiPageService.canonicalTitle("Erwei-Badu_San"));
|
||||
assertEquals("erweibadusan", WikiPageService.canonicalTitle("er wei badu san"));
|
||||
// Chinese title with surrounding/full-width whitespace and an inserted hyphen.
|
||||
assertEquals("二味拔毒散", WikiPageService.canonicalTitle(" 二味-拔毒散 "));
|
||||
assertEquals(WikiPageService.canonicalTitle("二味拔毒散"),
|
||||
WikiPageService.canonicalTitle("二味拔毒散 "));
|
||||
// Null / blank degrade to empty so callers can short-circuit.
|
||||
assertEquals("", WikiPageService.canonicalTitle(null));
|
||||
assertEquals("", WikiPageService.canonicalTitle(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByCanonicalTitleMatchesAcrossDifferentSlugs() {
|
||||
// Same concept already stored under an LLM-chosen slug; a later run arrives
|
||||
// with the same title but would have minted a different slug. Title match
|
||||
// must find the existing row regardless of the slug spelling.
|
||||
WikiPageMapper mapper = mock(WikiPageMapper.class);
|
||||
WikiPageEntity summary = new WikiPageEntity();
|
||||
summary.setKbId(7L);
|
||||
summary.setSlug("erwei-badu-san");
|
||||
summary.setTitle("二味拔毒散");
|
||||
WikiPageEntity full = new WikiPageEntity();
|
||||
full.setId(42L);
|
||||
full.setKbId(7L);
|
||||
full.setSlug("erwei-badu-san");
|
||||
full.setTitle("二味拔毒散");
|
||||
// listSummaries() -> selectList ; getBySlug() -> selectOne
|
||||
when(mapper.selectList(any())).thenReturn(List.of(summary));
|
||||
when(mapper.selectOne(any())).thenReturn(full);
|
||||
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
WikiPageService service = new WikiPageService(mapper, om, new WikiLinkService(om));
|
||||
|
||||
WikiPageEntity hit = service.findByCanonicalTitle(7L, "二味拔毒散");
|
||||
assertNotNull(hit);
|
||||
assertEquals(42L, hit.getId());
|
||||
assertEquals("erwei-badu-san", hit.getSlug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByCanonicalTitleReturnsNullWhenNoConceptMatches() {
|
||||
WikiPageMapper mapper = mock(WikiPageMapper.class);
|
||||
WikiPageEntity summary = new WikiPageEntity();
|
||||
summary.setKbId(7L);
|
||||
summary.setSlug("shennong-bencao");
|
||||
summary.setTitle("神农本草经");
|
||||
when(mapper.selectList(any())).thenReturn(List.of(summary));
|
||||
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
WikiPageService service = new WikiPageService(mapper, om, new WikiLinkService(om));
|
||||
|
||||
assertNull(service.findByCanonicalTitle(7L, "二味拔毒散"));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user