diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
index 31df23be..15cf25a7 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java
@@ -38,6 +38,16 @@ public class WikiPageEntity {
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String outgoingLinks;
+ /**
+ * Alternate concept names this page also covers (JSON array, e.g.
+ * ["叶绿体","线粒体"]). Set for discrimination / composite pages that absorb
+ * several fine-grained concepts which never became standalone pages. The
+ * post-ingestion link reconciler uses these so a [[叶绿体]] reference from
+ * another page resolves to this page instead of dangling.
+ */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS)
+ private String aliases;
+
/** 来源原始材料 ID(JSON 数组) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String sourceRawIds;
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java
index 67e9a583..b7c5d1a0 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLinkService.java
@@ -9,6 +9,7 @@ import vip.mate.wiki.model.WikiPageEntity;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -114,19 +115,27 @@ public class WikiLinkService {
}
/**
- * Compute the broken subset of {@code outlinks} given the KB's active
- * page slug set. {@code activeSlugs} is expected to be already lowercased
- * — callers compute it once per scan and reuse across pages.
+ * Compute the broken subset of {@code outlinks} given the KB's set of
+ * resolvable link-target keys. {@code resolvableKeysLower} is expected to
+ * be already lowercased and to contain BOTH page slugs and page titles
+ * (see {@link #resolvableTargetKeys}) — callers compute it once per scan
+ * and reuse it across pages.
+ *
+ * A target counts as broken only when it matches neither a slug nor a
+ * title, mirroring the page viewer's {@code resolveWikilink}. Matching on
+ * slugs alone would report every {@code [[Page Title]]} reference to an
+ * existing page as broken even though the viewer renders it as a working
+ * link.
*
- * @return targets that have no matching page slug, in the same insertion
+ * @return targets that resolve to no existing page, in the same insertion
* order as {@code outlinks}
*/
- public List computeBrokenLinks(Set outlinks, Set activeSlugsLower) {
+ public List computeBrokenLinks(Set outlinks, Set resolvableKeysLower) {
if (outlinks == null || outlinks.isEmpty()) return Collections.emptyList();
- if (activeSlugsLower == null) activeSlugsLower = Collections.emptySet();
+ if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet();
List broken = new ArrayList<>();
for (String t : outlinks) {
- if (!activeSlugsLower.contains(t)) broken.add(t);
+ if (!resolvableKeysLower.contains(t)) broken.add(t);
}
return broken;
}
@@ -134,11 +143,12 @@ public class WikiLinkService {
/**
* Convenience: extract + compute in one call. Used from
* {@code WikiPageService.save/update} where both fields are written in
- * the same transaction.
+ * the same transaction. {@code resolvableKeysLower} should carry slugs
+ * and titles — see {@link #resolvableTargetKeys}.
*/
- public LinkAnalysis analyze(String content, Set activeSlugsLower) {
+ public LinkAnalysis analyze(String content, Set resolvableKeysLower) {
Set outlinks = extractOutlinks(content);
- List broken = computeBrokenLinks(outlinks, activeSlugsLower);
+ List broken = computeBrokenLinks(outlinks, resolvableKeysLower);
return new LinkAnalysis(new ArrayList<>(outlinks), broken);
}
@@ -181,6 +191,45 @@ public class WikiLinkService {
.collect(Collectors.toUnmodifiableSet());
}
+ /**
+ * Build the set of resolvable wikilink-target keys for a KB from a
+ * pre-loaded page list — the union of each page's lowercased slug AND its
+ * trimmed-lowercased title.
+ *
+ * This is the broken-link counterpart to {@link #lowercaseSlugSet} and is
+ * what {@link #computeBrokenLinks} should be fed: it mirrors the page
+ * viewer's {@code resolveWikilink}, which resolves a {@code [[target]]}
+ * against an exact slug OR an exact title before declaring it broken.
+ * Using slugs alone flags every {@code [[Page Title]]} reference to an
+ * existing page as broken even though the viewer renders it as a working
+ * link — a false positive that is pervasive when slugs are transliterated
+ * (e.g. a CJK title {@code 光合作用} stored under the pinyin slug
+ * {@code guanghe-zuoyong}).
+ *
+ * Titles are trimmed before lowercasing to match {@code extractOutlinks},
+ * which trims a {@code [[ Page Title ]]} target before recording it.
+ *
+ * @param pages active pages (callers filter out archived); both slug and
+ * title columns must be loaded
+ * @return mutable set of lowercased slug + title keys; empty for null/empty
+ */
+ public Set resolvableTargetKeys(List pages) {
+ if (pages == null || pages.isEmpty()) return new HashSet<>();
+ Set keys = new HashSet<>(pages.size() * 2);
+ for (WikiPageEntity p : pages) {
+ if (p == null) continue;
+ String slug = p.getSlug();
+ if (slug != null && !slug.isBlank()) {
+ keys.add(slug.toLowerCase(Locale.ROOT));
+ }
+ String title = p.getTitle();
+ if (title != null && !title.isBlank()) {
+ keys.add(title.trim().toLowerCase(Locale.ROOT));
+ }
+ }
+ return keys;
+ }
+
// ============================================================
// Cascade rewrite — used by page delete + rename to update referrers
// ============================================================
@@ -236,6 +285,48 @@ public class WikiLinkService {
});
}
+ /**
+ * Decides what to do with one wikilink during reconciliation. Receives the
+ * original-case target (before any {@code |alias}) and the explicit alias
+ * (or {@code null}); returns {@code null} to leave the link untouched, or
+ * the literal replacement text — plain text to demote a dangling link, or a
+ * re-formed {@code [[coverSlug|display]]} to redirect it to a covering page.
+ */
+ @FunctionalInterface
+ public interface LinkReconciler {
+ String reconcile(String target, String alias);
+ }
+
+ /**
+ * Walk {@code content} and hand every wikilink to {@code reconciler}, used
+ * by the post-ingestion pass that redirects or demotes links the model
+ * wrote to concepts that never became their own page. Mirrors
+ * {@link #rewriteWikilinks} (code spans preserved verbatim) but passes the
+ * original-case target so the reconciler can build readable display text.
+ */
+ public String reconcileLinks(String content, LinkReconciler reconciler) {
+ if (content == null || content.isEmpty()) return content;
+ List regions = splitByCode(content);
+ StringBuilder out = new StringBuilder(content.length() + 16);
+ for (Region r : regions) {
+ if (r.isCode) { out.append(r.text); continue; }
+ Matcher m = WIKILINK.matcher(r.text);
+ int last = 0;
+ while (m.find()) {
+ out.append(r.text, last, m.start());
+ String raw = m.group(1).trim();
+ int pipe = raw.indexOf('|');
+ String target = (pipe >= 0 ? raw.substring(0, pipe) : raw).trim();
+ String alias = pipe >= 0 ? raw.substring(pipe + 1).trim() : null;
+ String replacement = target.isEmpty() ? null : reconciler.reconcile(target, alias);
+ out.append(replacement == null ? m.group() : replacement);
+ last = m.end();
+ }
+ out.append(r.text, last, r.text.length());
+ }
+ return out.toString();
+ }
+
/**
* Walk {@code content} replacing wikilinks via {@code rewriter}. Code
* spans are detected and restored verbatim — replacement only happens in
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
index 7cf2b3a2..f52eebef 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
@@ -216,11 +216,14 @@ public class WikiLintJobService {
* the whole scan.
*/
private ScanCounts scan(Long kbId) {
- // listSummaries gives us the active (non-archived) page slug set —
- // archived pages are NOT considered as valid targets, matching the
- // resolver's behaviour.
+ // listSummaries gives us the active (non-archived) pages — archived
+ // pages are NOT considered as valid targets, matching the resolver's
+ // behaviour. The key set carries both slugs AND titles so a
+ // `[[Page Title]]` reference to an existing page resolves the same way
+ // the viewer renders it, instead of being reported as a false-positive
+ // broken link.
List summaries = pageService.listSummaries(kbId);
- Set activeSlugs = linkService.lowercaseSlugSet(summaries);
+ Set activeSlugs = linkService.resolvableTargetKeys(summaries);
// Now fetch the same pages WITH content so we can re-extract outlinks.
// We must not use listSummaries here because it omits the content
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
index 66156b16..a10b0652 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
@@ -767,25 +767,23 @@ public class WikiPageService {
kbId, deletedPageId, likePattern);
if (candidates.isEmpty()) return List.of();
- // Pre-compute the active slug set ONCE for the recompute pass — every
- // referrer's broken_links recompute would otherwise re-trigger the
- // summary query.
+ // Pre-compute the resolvable target keys (slugs + titles) ONCE for the
+ // recompute pass — every referrer's broken_links recompute would
+ // otherwise re-trigger the summary query.
Set activeSlugs;
try {
- activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId));
+ activeSlugs = linkService.resolvableTargetKeys(listSummaries(kbId));
} catch (RuntimeException e) {
- activeSlugs = java.util.Collections.emptySet();
+ activeSlugs = new HashSet<>();
}
// The deleted page is, by construction, no longer "active" — remove
- // its slug from the set so any referrers' broken_links recompute
- // doesn't accidentally still resolve `[[deletedSlug]]` in their
- // (now-rewritten) content.
- if (!activeSlugs.contains(slugLower)) {
- // already missing — common case
- } else {
- Set trimmed = new HashSet<>(activeSlugs);
- trimmed.remove(slugLower);
- activeSlugs = trimmed;
+ // its slug AND title from the set so any referrers' broken_links
+ // recompute doesn't accidentally still resolve `[[deletedSlug]]` or
+ // `[[Deleted Title]]` in their (now-rewritten) content if listSummaries
+ // returned a stale cache that still included the deleted page.
+ activeSlugs.remove(slugLower);
+ if (snapshotTitle != null && !snapshotTitle.isBlank()) {
+ activeSlugs.remove(snapshotTitle.trim().toLowerCase(Locale.ROOT));
}
List affected = new ArrayList<>(candidates.size());
@@ -922,18 +920,17 @@ public class WikiPageService {
Set activeSlugs;
try {
- activeSlugs = linkService.lowercaseSlugSet(listSummaries(kbId));
+ activeSlugs = linkService.resolvableTargetKeys(listSummaries(kbId));
} catch (RuntimeException e) {
- activeSlugs = java.util.Collections.emptySet();
+ activeSlugs = new HashSet<>();
}
// The renamed page is now under newSlug; oldSlug is gone, newSlug
// should resolve. listSummaries has been evicted above so this picks
// up the new row when re-queried, but be defensive in case the cache
- // hasn't repopulated yet.
- Set activeBase = new HashSet<>(activeSlugs);
- activeBase.remove(slugLower);
- activeBase.add(newSlug.toLowerCase(Locale.ROOT));
- activeSlugs = activeBase;
+ // hasn't repopulated yet. The title is unchanged by a rename, so it
+ // stays resolvable via the title key carried in the set.
+ activeSlugs.remove(slugLower);
+ activeSlugs.add(newSlug.toLowerCase(Locale.ROOT));
List affected = new ArrayList<>(candidates.size());
for (WikiPageEntity referrer : candidates) {
@@ -1287,28 +1284,125 @@ public class WikiPageService {
// update path) and every extracted target is recorded as broken —
// which is harmless because tests don't assert on broken_links
// values, and production code paths never hit this branch.
- Set activeSlugs;
+ Set resolvableKeys;
try {
- activeSlugs = linkService.lowercaseSlugSet(listSummaries(entity.getKbId()));
+ resolvableKeys = linkService.resolvableTargetKeys(listSummaries(entity.getKbId()));
} catch (RuntimeException e) {
- log.warn("[Wiki] applyLinkAnalysis: failed to load slug set for kbId={}, treating as empty: {}",
+ log.warn("[Wiki] applyLinkAnalysis: failed to load target keys for kbId={}, treating as empty: {}",
entity.getKbId(), e.toString());
- activeSlugs = java.util.Collections.emptySet();
+ resolvableKeys = new HashSet<>();
}
- // Include self-slug so [[my-own-slug]] doesn't appear as broken on the
- // very save that creates the page (listSummaries may not see it yet
- // depending on cache state).
+ // Include self slug + title so [[my-own-slug]] / [[My Own Title]] don't
+ // appear as broken on the very save that creates the page (listSummaries
+ // may not see it yet depending on cache state).
if (entity.getSlug() != null && !entity.getSlug().isBlank()) {
- Set withSelf = new HashSet<>(activeSlugs);
- withSelf.add(entity.getSlug().toLowerCase(Locale.ROOT));
- activeSlugs = withSelf;
+ resolvableKeys.add(entity.getSlug().toLowerCase(Locale.ROOT));
}
- WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), activeSlugs);
+ if (entity.getTitle() != null && !entity.getTitle().isBlank()) {
+ resolvableKeys.add(entity.getTitle().trim().toLowerCase(Locale.ROOT));
+ }
+ WikiLinkService.LinkAnalysis a = linkService.analyze(entity.getContent(), resolvableKeys);
entity.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
entity.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
entity.setBrokenLinksScannedAt(LocalDateTime.now());
}
+ /**
+ * Merge {@code newAliases} into the page identified by {@code title} (the
+ * canonical concept identity used for dedup). Aliases are the alternate
+ * concept names a discrimination / composite page also covers; they let the
+ * post-ingestion reconciler redirect {@code [[concept]]} references that
+ * never became their own page. The page's own title is never stored as an
+ * alias of itself. No-op when the page or alias list is empty.
+ */
+ public void mergeAliasesByTitle(Long kbId, String title, List newAliases) {
+ if (kbId == null || title == null || newAliases == null || newAliases.isEmpty()) return;
+ WikiPageEntity page = findByCanonicalTitle(kbId, title);
+ if (page == null) page = getBySlug(kbId, toSlug(title));
+ if (page == null) return;
+ Set merged = new java.util.LinkedHashSet<>(linkService.fromJsonArray(page.getAliases()));
+ String ownTitle = page.getTitle() == null ? "" : page.getTitle().trim();
+ boolean added = false;
+ for (String a : newAliases) {
+ String t = a == null ? "" : a.trim();
+ if (t.isEmpty() || t.equalsIgnoreCase(ownTitle)) continue;
+ if (merged.add(t)) added = true;
+ }
+ if (!added) return;
+ pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, page.getId())
+ .set(WikiPageEntity::getAliases, linkService.toJsonArray(new ArrayList<>(merged))));
+ evictSummaryCache(kbId);
+ }
+
+ /**
+ * Post-ingestion link reconciliation for a KB. Each {@code [[target]]} the
+ * model wrote is handled by where its concept ended up:
+ *
+ * - resolves to a real page (slug or title) → left untouched;
+ * - matches another page's declared alias → rewritten to
+ * {@code [[coverSlug|target]]} so it links to the covering page;
+ * - otherwise → demoted to plain text (the concept name), removing the
+ * dangling link entirely.
+ *
+ * Only pages whose content actually changes are re-persisted, so re-running
+ * after a settled KB is a cheap no-op. Touches content + outgoing_links
+ * only — the caller recomputes broken_links via the lint scan afterwards.
+ *
+ * @return the number of pages rewritten
+ */
+ public int reconcileKbLinks(Long kbId) {
+ if (kbId == null) return 0;
+ List pages = pageMapper.selectList(
+ new LambdaQueryWrapper()
+ .select(WikiPageEntity::getId, WikiPageEntity::getSlug, WikiPageEntity::getTitle,
+ WikiPageEntity::getContent, WikiPageEntity::getAliases)
+ .eq(WikiPageEntity::getKbId, kbId)
+ .ne(WikiPageEntity::getArchived, 1));
+ if (pages.isEmpty()) return 0;
+ Set resolvable = linkService.resolvableTargetKeys(pages);
+ // alias (lowercased) → covering page slug; first declarer wins, and a
+ // name owned by a real page is never treated as an alias.
+ Map aliasToSlug = new LinkedHashMap<>();
+ for (WikiPageEntity p : pages) {
+ if (p.getSlug() == null || p.getSlug().isBlank()) continue;
+ for (String a : linkService.fromJsonArray(p.getAliases())) {
+ String key = a == null ? "" : a.trim().toLowerCase(Locale.ROOT);
+ if (key.isEmpty() || resolvable.contains(key)) continue;
+ aliasToSlug.putIfAbsent(key, p.getSlug());
+ }
+ }
+ int changed = 0;
+ for (WikiPageEntity p : pages) {
+ String content = p.getContent();
+ if (content == null || !content.contains("[[")) continue;
+ String selfSlug = p.getSlug() == null ? "" : p.getSlug().toLowerCase(Locale.ROOT);
+ String reconciled = linkService.reconcileLinks(content, (target, alias) -> {
+ String key = target.trim().toLowerCase(Locale.ROOT);
+ if (resolvable.contains(key)) return null; // real page — keep
+ String display = (alias != null && !alias.isBlank()) ? alias : target;
+ String coverSlug = aliasToSlug.get(key);
+ if (coverSlug != null && !coverSlug.toLowerCase(Locale.ROOT).equals(selfSlug)) {
+ return "[[" + coverSlug + "|" + display + "]]"; // redirect to covering page
+ }
+ return display; // demote dangling link to plain text
+ });
+ if (!reconciled.equals(content)) {
+ List outlinks = new ArrayList<>(linkService.extractOutlinks(reconciled));
+ pageMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, p.getId())
+ .set(WikiPageEntity::getContent, reconciled)
+ .set(WikiPageEntity::getOutgoingLinks, linkService.toJsonArray(outlinks)));
+ changed++;
+ }
+ }
+ if (changed > 0) {
+ evictSummaryCache(kbId);
+ log.info("[Wiki] Link reconciliation rewrote {} page(s) for kbId={}", changed, kbId);
+ }
+ return changed;
+ }
+
/**
* 将标题转换为 slug(URL 安全标识符)
*/
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 70656254..e02f9282 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
@@ -118,6 +118,15 @@ public class WikiProcessingService {
@org.springframework.beans.factory.annotation.Autowired(required = false)
private WikiScaffoldService scaffoldService;
+ /**
+ * Recomputes broken links once a raw material finishes processing. Optional
+ * (field-injected) so unit tests that construct this service directly
+ * without Spring don't need to supply it — when absent, the post-ingestion
+ * auto-scan is simply skipped.
+ */
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ private WikiLintJobService lintJobService;
+
/**
* RFC-051 PR-3: optional model routing service. When wired, route /
* create_page / merge_page LLM calls inside the eager pipeline ask the
@@ -537,6 +546,41 @@ public class WikiProcessingService {
ProgressCounter pc = progressCounters.remove(rawId);
if (pc != null) {
rawService.updateProgress(rawId, "done", pc.done.get(), pc.total.get());
+ // Once every material in the KB has settled, reconcile links and
+ // recompute broken_links. Gating on "no unsettled raws" avoids
+ // two hazards of doing this per-material mid-batch: (1) demoting a
+ // [[concept]] link before a later material creates that page, and
+ // (2) recording a link to a later-created page as broken. The last
+ // material to finish runs both; earlier completions skip. Both
+ // steps are idempotent, so a rare concurrent double-run is benign.
+ boolean kbSettled;
+ try {
+ kbSettled = rawService.listByKbId(kb.getId()).stream()
+ .noneMatch(r -> "pending".equals(r.getProcessingStatus())
+ || "processing".equals(r.getProcessingStatus()));
+ } catch (RuntimeException e) {
+ kbSettled = true; // best-effort: prefer reconciling over skipping
+ }
+ if (kbSettled) {
+ try {
+ // Redirect alias-covered links to their covering page and
+ // demote the genuinely uncovered ones to plain text before
+ // the scan, so freshly imported content doesn't surface
+ // dangling links to concepts that were merged away.
+ pageService.reconcileKbLinks(kb.getId());
+ } catch (RuntimeException reconErr) {
+ log.warn("[Wiki] post-ingestion link reconciliation failed for kbId={}: {}",
+ kb.getId(), reconErr.toString());
+ }
+ if (lintJobService != null) {
+ try {
+ lintJobService.startOrGetRunning(kb.getId());
+ } catch (RuntimeException scanErr) {
+ log.warn("[Wiki] post-ingestion broken-link scan trigger failed for kbId={}: {}",
+ kb.getId(), scanErr.toString());
+ }
+ }
+ }
}
}
}
@@ -1303,6 +1347,17 @@ public class WikiProcessingService {
}
JsonNode metadataNode = pageJson.path("metadata");
JsonNode dependsOnNode = pageJson.path("depends_on");
+ // Alternate concept names this page covers (composite / 辨析
+ // pages list the fine-grained concepts they absorbed) — used by
+ // the post-ingestion reconciler to redirect [[concept]] links.
+ List pageAliases = new ArrayList<>();
+ JsonNode aliasesNode = pageJson.path("aliases");
+ if (aliasesNode.isArray()) {
+ for (JsonNode a : aliasesNode) {
+ String s = a.asText("").trim();
+ if (!s.isEmpty()) pageAliases.add(s);
+ }
+ }
if (content.isBlank()) {
log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug);
final String blankSlug = slug;
@@ -1332,6 +1387,13 @@ public class WikiProcessingService {
boolean ok = false;
try {
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType, metadataNode, dependsOnNode);
+ if (!pageAliases.isEmpty()) {
+ try {
+ pageService.mergeAliasesByTitle(kbId, title, pageAliases);
+ } catch (RuntimeException aliasErr) {
+ log.warn("[Wiki] Failed to persist aliases for title='{}': {}", title, aliasErr.toString());
+ }
+ }
if (wasCreated) {
created.incrementAndGet();
totalCreated++;
@@ -1546,6 +1608,22 @@ public class WikiProcessingService {
Long kbId = kb.getId();
Long rawId = raw.getId();
+ // Derive the slug deterministically from the title rather than trusting
+ // the model-supplied one. A model-minted slug romanizes inconsistently
+ // across runs (the same concept lands under different spellings) and
+ // forces every [[...]] reference to guess a transliteration that often
+ // misses — surfacing as broken links on a freshly imported KB. Title is
+ // already the canonical concept identity used for dedup below, so keying
+ // the slug off it keeps the stored slug and the human-meaningful title
+ // from ever drifting apart, and makes [[Title]] references resolve. Falls
+ // back to the supplied slug only when the title yields no usable slug.
+ if (title != null && !title.isBlank()) {
+ String derivedSlug = WikiPageService.toSlug(title);
+ if (derivedSlug != null && !derivedSlug.isBlank()) {
+ slug = derivedSlug;
+ }
+ }
+
// Refuse to materialize a page, or merge into an existing one, for a
// raw the user just deleted. This prevents pages whose source_raw_ids
// point at a tombstoned row.
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql
new file mode 100644
index 00000000..a949ac57
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V147__wiki_page_aliases.sql
@@ -0,0 +1,12 @@
+-- V147: Page aliases — alternate concept names a page also covers.
+--
+-- aliases JSON array of alternate names (concepts) that this page covers but
+-- that did not become standalone pages — e.g. a "细胞器术语辨析"
+-- discrimination page covers 叶绿体 / 线粒体 / 高尔基体. The
+-- post-ingestion link reconciler consults this so a [[叶绿体]]
+-- reference written by another page is rewritten to point at the
+-- covering page ([[细胞器术语辨析|叶绿体]]) instead of dangling as a
+-- broken link. NULL or empty array means the page declares no extra
+-- names. Distinct from the title (the page's primary identity) and
+-- outgoing_links (targets this page links out to).
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS aliases TEXT DEFAULT NULL;
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql
new file mode 100644
index 00000000..a2e03945
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__wiki_page_aliases.sql
@@ -0,0 +1,5 @@
+-- V147: Page aliases — KingbaseES dialect.
+--
+-- See h2/V147__wiki_page_aliases.sql for column semantics.
+-- KingbaseES (PostgreSQL) supports ADD COLUMN IF NOT EXISTS natively.
+ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS aliases TEXT DEFAULT NULL;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql
new file mode 100644
index 00000000..df1eb368
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V147__wiki_page_aliases.sql
@@ -0,0 +1,15 @@
+-- V147: Page aliases — MySQL dialect.
+--
+-- See h2/V147__wiki_page_aliases.sql for column semantics. MySQL needs an
+-- INFORMATION_SCHEMA guard because ADD COLUMN IF NOT EXISTS is unavailable on
+-- the older 8.0.x versions the deploy targets.
+SET @col_exists := (
+ SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'mate_wiki_page'
+ AND COLUMN_NAME = 'aliases'
+);
+SET @stmt := IF(@col_exists = 0,
+ 'ALTER TABLE mate_wiki_page ADD COLUMN aliases JSON DEFAULT NULL COMMENT ''Alternate concept names this page also covers''',
+ 'SELECT 1');
+PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt
index eb0804b7..8d24d078 100644
--- a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt
+++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt
@@ -32,10 +32,10 @@
## slug 规范(仅用于 `key_concepts` 中的建议 slug)
-- 多音节中文词按整词分组拼音,不要按字一隔
- - ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
- - ❌ `zhong-yao-qi-qing-pei-wu`
-- 小写字母 + 连字符,无空格
+- **直接用概念名本身**:中文保留中文(**不要转拼音**),英文小写、空格转连字符
+ - ✅ `光合作用`、`energy-metabolism`
+ - ❌ `guanghe-zuoyong`(不要转拼音)
+- slug 与概念名用词保持一致,无空格
## 关键纪律
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt
index fd674d15..14ed74d4 100644
--- a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt
+++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt
@@ -24,12 +24,12 @@
## 链接(**单一契约,必须严格遵守**)
- 只允许两种形态:
- - `[[slug]]` —— 显示文本默认为目标页标题
- - `[[slug|显示文本]]` —— 显示文本自定义
-- slug **必须**来自以下两类来源之一:
- - **已有 Wiki 页面索引**(user prompt 中列出)—— 这些链接是**强保证**,slug 100% 可用
- - **本批次同时创建的页面**(即 `pages_to_create` 数组中的 slug)—— 这类链接**不保证成功**:本批次中的页面可能因去重 / 合并 / 失败而最终未落库,导致链接转为死链;这是预期行为,系统会在写入后由 lint 标记并由人工修复
-- 禁止发明上述两类来源之外的 slug;禁止写 `[[页面标题]]` 形态 —— 系统按 slug 严格匹配,写标题会被识别为死链
+ - `[[目标]]` —— 显示文本默认为目标页标题
+ - `[[目标|显示文本]]` —— 显示文本自定义
+- `目标`可以写页面的 **slug 或 title**(系统两者都能解析),且**必须**来自以下两类来源之一:
+ - **已有 Wiki 页面索引**(user prompt 中列出)—— 这些链接是**强保证**,目标 100% 可用
+ - **本批次同时创建的页面**(即 `pages_to_create` 数组中的 slug / title)—— 这类链接**不保证成功**:本批次中的页面可能因去重 / 合并 / 失败而最终未落库,导致链接转为死链;这是预期行为,系统会在写入后由 lint 标记并由人工修复
+- 禁止链接到上述两类来源之外的目标——不要发明不存在的页面
## 输出格式(严格遵守)
@@ -53,6 +53,7 @@
- `page_type`:页面类型,从下面"允许的页面类型"列表中选一个;都不合适时选 concept。
- `metadata`(可选):与所选 page_type 对应的结构化字段对象,只输出该类型声明的字段(带"required metadata"标注的字段应尽量补全)。
- `depends_on`(可选,仅经验层页面需要):本页所依赖的**事实层页面 slug 数组**。经验层(如 analysis/pattern/regime)必须列出其结论所基于的事实页 slug;事实层页面留空或不输出。
+- `aliases`(可选,强烈建议用于"辨析/综合"类页面):本页**虽然讲到、但没有单独成页**的细粒度概念名数组。例如一个「细胞器术语辨析」页同时讲了叶绿体、线粒体、高尔基体,却不会为它们各建一页,就写 `"aliases":["叶绿体","线粒体","高尔基体"]`。这样别处写的 `[[叶绿体]]` 会被系统自动指向本页,而不是变成死链。只列**本页确实充分讲解**的概念;不要列本页标题本身,也不要列已经独立成页的概念。
允许的页面类型:
{allowed_page_types}
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt
index 554855b1..65946955 100644
--- a/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt
+++ b/mateclaw-server/src/main/resources/prompts/wiki/route-system.txt
@@ -30,13 +30,11 @@
## metadata 格式(仅 `create` 数组使用)
每条 metadata 包含三个字段:
-- `slug`:URL 安全的标识符(小写字母 + 连字符)。
- - **多音节中文词的拼音必须按整词分组、不要按字隔开**。
- - ✅ 正确:`shennong-bencao-jing`(神农 / 本草 / 经 三个词)
- - ❌ 错误:`shen-nong-ben-cao-jing`(按字一隔,会被识别为另一概念)
- - ✅ 正确:`zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
- - ❌ 错误:`zhong-yao-qi-qing-pei-wu`
- - **同一概念在不同段落必须用同一 slug**:选定一个 slug 就坚持用,不要换写法。
+- `slug`:页面标识符,**直接用概念名本身**——中文保留中文(**不要转拼音 / 罗马字**),英文小写、空格转连字符、去掉标点符号。
+ - ✅ 正确:`光合作用`、`神农本草经`、`energy-metabolism`
+ - ❌ 错误:`guanghe-zuoyong`、`shennong-bencao-jing`(不要转拼音)
+ - slug 应与 `title` 用词一致;系统会按标题自动规范化 slug,所以**保持 slug 与 title 一致**即可。
+ - **同一概念在不同段落必须用同一写法**:选定后就坚持用,不要换写法。
- `title`:人类可读的页面标题
- `summary`:一段话简短摘要(一两句话即可,让"单页生成助手"知道这一页要写什么)
diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java
new file mode 100644
index 00000000..3e3925c5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiLinkServiceBrokenLinkTest.java
@@ -0,0 +1,188 @@
+package vip.mate.wiki.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import vip.mate.wiki.model.WikiPageEntity;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Coverage for broken-link computation, focused on the slug-or-title
+ * resolution contract that {@link WikiLinkService#resolvableTargetKeys} and
+ * {@link WikiLinkService#computeBrokenLinks} jointly enforce.
+ *
+ * The page viewer's {@code resolveWikilink} treats a {@code [[target]]} as
+ * a hit when it matches either an existing page slug OR an existing page title.
+ * The lint must agree, otherwise a {@code [[Page Title]]} reference to a real
+ * page is rendered as a working link yet reported as a broken link — the
+ * false-positive class this test pins down. The mismatch is most visible when
+ * slugs are transliterated (a CJK title stored under a pinyin slug).
+ */
+class WikiLinkServiceBrokenLinkTest {
+
+ private final WikiLinkService svc = new WikiLinkService(new ObjectMapper());
+
+ private static WikiPageEntity page(String slug, String title) {
+ WikiPageEntity p = new WikiPageEntity();
+ p.setSlug(slug);
+ p.setTitle(title);
+ return p;
+ }
+
+ /** Mirrors the real KB in issue #333: Chinese titles, pinyin slugs. */
+ private List cjkKb() {
+ return List.of(
+ page("guanghe-zuoyong", "光合作用"),
+ page("xianliti", "线粒体"),
+ page("yelvti", "叶绿体"),
+ page("nengliang-daixie", "能量代谢"),
+ page("energy-metabolism", "Energy Metabolism"));
+ }
+
+ @Test
+ void resolvableKeysCarryBothSlugAndTitle() {
+ Set keys = svc.resolvableTargetKeys(cjkKb());
+ assertTrue(keys.contains("guanghe-zuoyong"), "slug must be a key");
+ assertTrue(keys.contains("光合作用"), "title must be a key");
+ assertTrue(keys.contains("energy-metabolism"));
+ assertTrue(keys.contains("energy metabolism"), "title lowercased, spaces kept");
+ }
+
+ @Test
+ void titleFormLinkToExistingCjkPageIsNotBroken() {
+ // The model naturally writes the readable title, not the pinyin slug.
+ String content = "参见 [[光合作用]] 与 [[线粒体]]。";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertTrue(a.brokenLinks().isEmpty(),
+ "title-form links to existing pages must resolve, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void slugFormLinkIsNotBroken() {
+ String content = "See [[guanghe-zuoyong]] and [[energy-metabolism]].";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertTrue(a.brokenLinks().isEmpty(), "slug-form links must resolve, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void englishTitleWithSpacesResolvesAgainstTitleNotSlug() {
+ // slug is "energy-metabolism" (dashed); the title has a space. Only the
+ // title key can match the [[Energy Metabolism]] target.
+ String content = "Read [[Energy Metabolism]] first.";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertTrue(a.brokenLinks().isEmpty(), "title-with-space must resolve, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void aliasFormResolvesOnTitleTarget() {
+ String content = "更多见 [[线粒体|线粒体别名]]。";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertTrue(a.brokenLinks().isEmpty(), "aliased title link must resolve, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void genuinelyMissingTargetIsStillBroken() {
+ String content = "悬挂引用 [[不存在的概念XYZ]] 应当被标记。";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertEquals(List.of("不存在的概念xyz"), a.brokenLinks(),
+ "a target matching no slug and no title must be broken");
+ }
+
+ @Test
+ void mixedContentReportsOnlyTheGenuineBreak() {
+ // Reproduces the issue scenario across several markdown shapes: only the
+ // hallucinated target is broken; the four title-form links resolve.
+ String content = String.join("\n",
+ "# 标题 [[能量代谢]]",
+ "段落:[[光合作用]] 与 [[guanghe-zuoyong]] 指向同一页。",
+ "- 列表:[[线粒体|别名]]",
+ "> 引用:[[叶绿体]]",
+ "悬挂:[[未知页面ABC]]");
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertEquals(List.of("未知页面abc"), a.brokenLinks(),
+ "only the hallucinated target is broken, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void linksInsideCodeAreNeitherOutgoingNorBroken() {
+ String content = String.join("\n",
+ "行内 `[[光合作用]]` 不计入。",
+ "```",
+ "围栏 [[不存在XYZ]] 不计入",
+ "```");
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(cjkKb()));
+ assertTrue(a.outgoingLinks().isEmpty(), "code-block links must be ignored, got: " + a.outgoingLinks());
+ assertTrue(a.brokenLinks().isEmpty(), "code-block links must not be broken, got: " + a.brokenLinks());
+ }
+
+ @Test
+ void emptyKbMakesEveryLinkBroken() {
+ String content = "[[anything]]";
+ WikiLinkService.LinkAnalysis a = svc.analyze(content, svc.resolvableTargetKeys(List.of()));
+ assertEquals(List.of("anything"), a.brokenLinks());
+ }
+
+ @Test
+ void computeBrokenLinksMatchesAgainstTitleKeys() {
+ Set keys = svc.resolvableTargetKeys(cjkKb());
+ // direct unit on the predicate: "光合作用" is a title key → resolvable
+ assertFalse(svc.computeBrokenLinks(Set.of("光合作用"), keys).contains("光合作用"));
+ assertTrue(svc.computeBrokenLinks(Set.of("missing"), keys).contains("missing"));
+ }
+
+ // ── reconcileLinks: post-ingestion redirect / demote of dangling links ──
+
+ /** Reconciler mirroring the production rule: keep resolvable, redirect via
+ * alias to a covering page, else demote to plain text. */
+ private WikiLinkService.LinkReconciler reconciler(Set resolvable,
+ java.util.Map aliasToSlug) {
+ return (target, alias) -> {
+ String key = target.trim().toLowerCase();
+ if (resolvable.contains(key)) return null;
+ String display = (alias != null && !alias.isBlank()) ? alias : target;
+ String cover = aliasToSlug.get(key);
+ if (cover != null) return "[[" + cover + "|" + display + "]]";
+ return display;
+ };
+ }
+
+ @Test
+ void reconcileKeepsResolvableLinks() {
+ Set resolvable = Set.of("光合作用", "guanghe-zuoyong");
+ String in = "见 [[光合作用]] 与 [[guanghe-zuoyong]]。";
+ String out = svc.reconcileLinks(in, reconciler(resolvable, java.util.Map.of()));
+ assertEquals(in, out, "resolvable links must be left untouched");
+ }
+
+ @Test
+ void reconcileRedirectsAliasToCoveringPage() {
+ Set resolvable = Set.of("细胞器术语辨析");
+ var aliasMap = java.util.Map.of("叶绿体", "细胞器术语辨析");
+ String out = svc.reconcileLinks("叶绿体见 [[叶绿体]]。", reconciler(resolvable, aliasMap));
+ assertEquals("叶绿体见 [[细胞器术语辨析|叶绿体]]。", out,
+ "an alias-covered link must redirect to the covering page, keeping a readable label");
+ }
+
+ @Test
+ void reconcileDemotesUncoveredDanglingToPlainText() {
+ Set resolvable = Set.of("细胞器术语辨析");
+ String out = svc.reconcileLinks("讲到 [[不存在的概念]] 和 [[未知|别名显示]]。",
+ reconciler(resolvable, java.util.Map.of()));
+ assertEquals("讲到 不存在的概念 和 别名显示。", out,
+ "uncovered links demote to plain text, honouring the alias display when present");
+ }
+
+ @Test
+ void reconcileLeavesCodeBlocksUntouched() {
+ String in = "正文 [[不存在]] 降级。\n\n```\n代码里的 [[不存在]] 保留\n```\n行内 `[[不存在]]` 保留。";
+ String out = svc.reconcileLinks(in, reconciler(Set.of(), java.util.Map.of()));
+ assertTrue(out.startsWith("正文 不存在 降级。"), "narrative link demoted, got: " + out);
+ assertTrue(out.contains("代码里的 [[不存在]] 保留"), "fenced code must be preserved");
+ assertTrue(out.contains("`[[不存在]]`"), "inline code must be preserved");
+ }
+}
diff --git a/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue b/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
index 36cdc482..9f8d960e 100644
--- a/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
+++ b/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksBanner.vue
@@ -93,7 +93,10 @@ const formattedTimestamp = computed(() => {
async function onScan() {
if (!store.currentKB) return
try {
- await store.startBrokenLinksScan(Number(store.currentKB.id))
+ // Pass the raw Snowflake id through untouched — Number()/parseInt() would
+ // truncate the 19-digit id past Number.MAX_SAFE_INTEGER and scan a KB that
+ // doesn't exist (the request 404s, so "rescan" silently does nothing).
+ await store.startBrokenLinksScan(store.currentKB.id)
} catch (e: any) {
console.error('[Wiki] scan failed', e)
}
diff --git a/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue b/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
index a5c6ce5a..9d6195ad 100644
--- a/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
+++ b/mateclaw-ui/src/views/Wiki/components/WikiBrokenLinksPanel.vue
@@ -65,7 +65,9 @@ const report = computed(() => store.brokenLinksReport)
async function onOpenPage(slug: string) {
if (!store.currentKB) return
- await store.loadPage(Number(store.currentKB.id), slug)
+ // Keep the Snowflake id as-is; Number() would truncate it and open a page
+ // lookup against a non-existent KB (per the project ID precision convention).
+ await store.loadPage(store.currentKB.id, slug)
emit('close')
}