





















@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
330330);
331331}
332332333-function normalizeConceptToken(rawToken: string): string | null {
333+function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
334334const normalized = normalizeLowercaseStringOrEmpty(
335335rawToken
336336.normalize("NFKC")
@@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null {
348348return null;
349349}
350350const script = classifyConceptTagScript(normalized);
351-if (normalized.length < minimumTokenLengthForScript(script)) {
351+// Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they
352+// bypass the per-script minimum length that would otherwise discard them.
353+if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) {
352354return null;
353355}
354356if (isKanaOnlyToken(normalized) && normalized.length < 3) {
@@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null {
360362return normalized;
361363}
362364365+// Only entries shorter than their script's minimum token length rely on the glossary bypass, and
366+// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer
367+// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups").
368+// Precomputed so derive() does not reclassify on every call.
369+const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({
370+ entry,
371+wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)),
372+}));
373+374+function isAlphanumericAt(source: string, index: number): boolean {
375+const ch = source[index];
376+return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch);
377+}
378+379+// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short
380+// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate.
381+function includesStandaloneTerm(source: string, entry: string): boolean {
382+let from = source.indexOf(entry);
383+while (from !== -1) {
384+if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) {
385+return true;
386+}
387+from = source.indexOf(entry, from + 1);
388+}
389+return false;
390+}
391+363392function collectGlossaryMatches(source: string): string[] {
364393const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC"));
365394const matches: string[] = [];
366-for (const entry of PROTECTED_GLOSSARY) {
367-if (!normalizedSource.includes(entry)) {
368-continue;
395+for (const { entry, wholeWord } of GLOSSARY_ENTRIES) {
396+const present = wholeWord
397+ ? includesStandaloneTerm(normalizedSource, entry)
398+ : normalizedSource.includes(entry);
399+if (present) {
400+matches.push(entry);
369401}
370-matches.push(entry);
371402}
372403return matches;
373404}
@@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] {
385416return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
386417}
387418388-function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
389-const normalized = normalizeConceptToken(rawToken);
419+function pushNormalizedTag(
420+tags: string[],
421+rawToken: string,
422+limit: number,
423+fromGlossary = false,
424+): void {
425+const normalized = normalizeConceptToken(rawToken, fromGlossary);
390426if (!normalized || tags.includes(normalized)) {
391427return;
392428}
@@ -410,14 +446,17 @@ export function deriveConceptTags(params: {
410446}
411447412448const tags: string[] = [];
413-for (const rawToken of [
414- ...collectGlossaryMatches(source),
415- ...collectCompoundTokens(source),
416- ...collectSegmentTokens(source),
417-]) {
418-pushNormalizedTag(tags, rawToken, limit);
419-if (tags.length >= limit) {
420-break;
449+const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [
450+{ tokens: collectGlossaryMatches(source), fromGlossary: true },
451+{ tokens: collectCompoundTokens(source), fromGlossary: false },
452+{ tokens: collectSegmentTokens(source), fromGlossary: false },
453+];
454+for (const { tokens, fromGlossary } of tokenSources) {
455+for (const rawToken of tokens) {
456+pushNormalizedTag(tags, rawToken, limit, fromGlossary);
457+if (tags.length >= limit) {
458+return tags;
459+}
421460}
422461}
423462return tags;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。