Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
189 lines
6.7 KiB
Go
189 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/searchutil"
|
|
)
|
|
|
|
// Fuzzy slug resolution: rescue LLM-generated `[[slug|display]]` links
|
|
// where the slug is *almost* right but not exactly the one in the KB.
|
|
//
|
|
// Background: the wiki ingest LLM is given an `<available_wiki_pages>`
|
|
// block listing exact `[[slug]] = title` entries. Empirically — even
|
|
// when the prompt explicitly says "copy-paste the slug verbatim" —
|
|
// open-source models such as deepseek-v3 routinely munge slugs in
|
|
// predictable ways:
|
|
//
|
|
// - inserting hyphens that happen to look like pinyin word breaks
|
|
// ("shanghaitower" → "shang-hai-tower"),
|
|
// - dropping or duplicating hyphens,
|
|
// - normalizing the case of an ASCII slug,
|
|
// - latinizing names that should have stayed CJK.
|
|
//
|
|
// The display text is almost always still correct (the model just
|
|
// copy-pastes the title), so we have two levers to find the *real*
|
|
// slug: a tolerant string compare on the slug itself, and a reverse
|
|
// lookup from display text. Combined, these recover the vast majority
|
|
// of broken links without any LLM cost — the alternative being
|
|
// stripDeadWikiLinks, which gives up and emits plain text.
|
|
//
|
|
// Cleanup callers (stripDeadWikiLinks, cleanDeadLinks) consult
|
|
// resolveDeadSlug FIRST and only fall back to "strip to plain text"
|
|
// when no live candidate is close enough to be safe.
|
|
|
|
// slugResolveBigramThreshold is the minimum char-bigram Jaccard
|
|
// similarity required for the bigram fallback to accept a candidate.
|
|
// 0.8 is intentionally conservative: at this level "shang-hai-tower"
|
|
// and "shanghai-tower" still match (most bigrams overlap), but
|
|
// "user-profile" and "user-permissions" do NOT (different stems even
|
|
// though the prefix matches). Anything weaker risks linking to the
|
|
// wrong page, which is worse than emitting plain text.
|
|
const slugResolveBigramThreshold = 0.8
|
|
|
|
// normalizeSlugForCompare collapses cosmetic slug variations so two
|
|
// slugs that differ only in hyphenation or case compare equal.
|
|
//
|
|
// Specifically:
|
|
// - lowercases ASCII letters (slugify already does this, so most
|
|
// real slugs are already lowercase, but the LLM occasionally
|
|
// produces capitalized forms),
|
|
// - removes every hyphen and underscore.
|
|
//
|
|
// Hyphens in wiki slugs are purely visual separators — slugify uses
|
|
// them between word fragments. Removing them lets us treat
|
|
// "shang-hai-tower" and "shanghai-tower" as the same logical token bag.
|
|
//
|
|
// CJK runes are preserved verbatim: they ARE the meaningful identity
|
|
// of CJK slugs, so collapsing them would over-merge.
|
|
func normalizeSlugForCompare(slug string) string {
|
|
slug = strings.ToLower(slug)
|
|
slug = strings.ReplaceAll(slug, "-", "")
|
|
slug = strings.ReplaceAll(slug, "_", "")
|
|
return slug
|
|
}
|
|
|
|
// resolveDeadSlug attempts to map a dead `[[slug]]` reference back to
|
|
// a live KB slug, using progressively more permissive heuristics:
|
|
//
|
|
// 1. Display-text reverse lookup. If the LLM emitted "[[bad-slug|上海
|
|
// 中心大厦]]" and there's a live page whose Title or alias is
|
|
// "上海中心大厦", return its slug. This is the most common case
|
|
// by far (the model copies the title correctly even when it
|
|
// mangles the slug).
|
|
//
|
|
// 2. Hyphen/case-normalized slug equality. Two slugs that compare
|
|
// equal under normalizeSlugForCompare are treated as the same
|
|
// logical page. Catches the LLM's "insert pinyin breaks" pattern.
|
|
//
|
|
// 3. Bigram Jaccard similarity ≥ slugResolveBigramThreshold (0.8).
|
|
// Catches typos and minor character substitutions that the
|
|
// normalize step doesn't fix. Computed via searchutil.Jaccard
|
|
// against character bigrams of each candidate slug.
|
|
//
|
|
// Returns the resolved live slug and true on success; "" and false
|
|
// when no candidate is close enough to be safe.
|
|
//
|
|
// The candidate pool is supplied by the caller as `liveSlugs`. The
|
|
// caller is also responsible for passing a `titleToSlug` map keyed
|
|
// by exact (case-sensitive) page title and alias surface forms. Both
|
|
// maps are consulted only — never mutated.
|
|
func resolveDeadSlug(
|
|
deadSlug string,
|
|
displayText string,
|
|
liveSlugs map[string]struct{},
|
|
titleToSlug map[string]string,
|
|
) (string, bool) {
|
|
if deadSlug == "" {
|
|
return "", false
|
|
}
|
|
// Already live? No-op success.
|
|
if _, ok := liveSlugs[deadSlug]; ok {
|
|
return deadSlug, true
|
|
}
|
|
|
|
// (1) Display-text reverse lookup. Trim whitespace because LLM
|
|
// occasionally emits `[[slug| display ]]` with stray spaces, and
|
|
// the user-side title would have been written without them.
|
|
if dt := strings.TrimSpace(displayText); dt != "" {
|
|
if slug, ok := titleToSlug[dt]; ok && slug != "" {
|
|
if _, live := liveSlugs[slug]; live {
|
|
return slug, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// (2) Normalized-equality check. Compute the dead slug's normal
|
|
// form once and compare against every live candidate's normal
|
|
// form. O(N) but N is bounded — this only fires on the dead-link
|
|
// path, and `liveSlugs` is the per-batch affected set, not the
|
|
// full KB.
|
|
deadNorm := normalizeSlugForCompare(deadSlug)
|
|
if deadNorm == "" {
|
|
// Slug was nothing but hyphens / underscores after normalize
|
|
// — nothing meaningful to compare against.
|
|
return "", false
|
|
}
|
|
for cand := range liveSlugs {
|
|
if normalizeSlugForCompare(cand) == deadNorm {
|
|
return cand, true
|
|
}
|
|
}
|
|
|
|
// (3) Bigram Jaccard fallback. Use searchutil.Jaccard against
|
|
// the character bigrams of each slug. We restrict the bigram
|
|
// alphabet to the slug's normalized form (lowercase, no hyphens)
|
|
// so a hyphenation difference doesn't dominate the bigram set
|
|
// the way it would for raw slug strings.
|
|
deadGrams := slugCharBigrams(deadNorm)
|
|
if len(deadGrams) == 0 {
|
|
return "", false
|
|
}
|
|
var (
|
|
bestSlug string
|
|
bestScore float64
|
|
)
|
|
for cand := range liveSlugs {
|
|
candNorm := normalizeSlugForCompare(cand)
|
|
if candNorm == "" {
|
|
continue
|
|
}
|
|
candGrams := slugCharBigrams(candNorm)
|
|
if len(candGrams) == 0 {
|
|
continue
|
|
}
|
|
score := searchutil.Jaccard(deadGrams, candGrams)
|
|
if score > bestScore {
|
|
bestScore = score
|
|
bestSlug = cand
|
|
}
|
|
}
|
|
if bestScore >= slugResolveBigramThreshold {
|
|
return bestSlug, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// slugCharBigrams returns a character-bigram set for the given (already
|
|
// normalized) slug. Single-rune slugs degrade to a 1-gram so they still
|
|
// contribute a comparable signal.
|
|
//
|
|
// Mirrors surfaceGrams in wiki_ingest_dedup.go but operates on a slug
|
|
// (which is already lowercased / hyphen-stripped) rather than a raw
|
|
// surface form, so we don't redo the lowercasing or punctuation
|
|
// stripping work.
|
|
func slugCharBigrams(s string) map[string]struct{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
runes := []rune(s)
|
|
if len(runes) == 1 {
|
|
return map[string]struct{}{string(runes): {}}
|
|
}
|
|
out := make(map[string]struct{}, len(runes))
|
|
for i := 0; i < len(runes)-1; i++ {
|
|
out[string(runes[i:i+2])] = struct{}{}
|
|
}
|
|
return out
|
|
}
|