feat: improve language validation with chunking for description

This commit is contained in:
tdgao
2026-08-29 00:59:27 -06:00
parent b13593e023
commit 2b12fe4668
4 changed files with 238 additions and 117 deletions
@@ -14,7 +14,7 @@ import {
import { validateSpam } from '../../validators/spam/index.ts' import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts' import { evaluateRules } from '../evaluate-rules.ts'
import { import {
evaluateEnglishText, evaluateEnglishTextBlocks,
evaluateNonStandardText, evaluateNonStandardText,
evaluateProfanity, evaluateProfanity,
evaluateSlur, evaluateSlur,
@@ -189,6 +189,16 @@ export function extractDescriptionText(markdown: string): string {
return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim() return withoutMarkdownSyntax.replace(/\s+/g, ' ').trim()
} }
export function extractDescriptionTextBlocks(markdown: string): string[] {
if (!markdown) return []
return markdown
.replace(/```[\s\S]*?```/g, '')
.split(/\n\s*\n+/)
.map(extractDescriptionText)
.filter(Boolean)
}
export function countText(markdown: string): number { export function countText(markdown: string): number {
return extractDescriptionText(markdown).length return extractDescriptionText(markdown).length
} }
@@ -248,12 +258,13 @@ export const projectDescriptionValidationRules = {
'project-description-non-english': { 'project-description-non-english': {
severity: 'warning', severity: 'warning',
evaluate: (description) => { evaluate: (description) => {
const text = extractDescriptionText(description ?? '') const blocks = extractDescriptionTextBlocks(description ?? '')
const text = blocks.join(' ')
if (text.length < MIN_DESCRIPTION_CHARS || !validateSpam(text).valid) { if (text.length < MIN_DESCRIPTION_CHARS || !validateSpam(text).valid) {
return { valid: true } return { valid: true }
} }
return evaluateEnglishText(text) return evaluateEnglishTextBlocks(blocks)
}, },
presentation: { presentation: {
message: messages.nonEnglish, message: messages.nonEnglish,
@@ -1,5 +1,9 @@
import type { ProjectValidationContext } from '../types/nags.ts' import type { ProjectValidationContext } from '../types/nags.ts'
import { validateEnglishSummaryText, validateEnglishText } from '../validators/language/index.ts' import {
validateEnglishSummaryText,
validateEnglishText,
validateEnglishTextBlocks,
} from '../validators/language/index.ts'
import { import {
getNonStandardTextRatio, getNonStandardTextRatio,
validateNonStandardText, validateNonStandardText,
@@ -53,6 +57,11 @@ export function evaluateEnglishText(text: string): ValidationRuleEvaluation {
return { valid: result.valid } return { valid: result.valid }
} }
export function evaluateEnglishTextBlocks(blocks: string[]): ValidationRuleEvaluation {
const result = validateEnglishTextBlocks(blocks)
return { valid: result.valid }
}
export function evaluateEnglishSummaryText(text: string): ValidationRuleEvaluation { export function evaluateEnglishSummaryText(text: string): ValidationRuleEvaluation {
const result = validateEnglishSummaryText(text) const result = validateEnglishSummaryText(text)
return { valid: result.valid } return { valid: result.valid }
@@ -8,11 +8,26 @@ export interface LanguageDetection {
export interface EnglishTextResult { export interface EnglishTextResult {
valid: boolean valid: boolean
detections: LanguageDetection[] detections: LanguageDetection[]
reasons: EnglishTextFailureReason[]
}
export type EnglishTextFailureReason = 'insufficient-english-chunk-coverage'
export interface LanguageChunkAnalysis {
totalChunks: number
englishChunks: number
nonEnglishChunks: number
ambiguousChunks: number
englishChunkPercentage: number | null
} }
export const MIN_LANGUAGE_DETECTION_WORDS = 8 export const MIN_LANGUAGE_DETECTION_WORDS = 8
export const MIN_LANGUAGE_DETECTION_CHARACTERS = 35 export const MIN_LANGUAGE_DETECTION_CHARACTERS = 35
export const MIN_ENGLISH_SCORE = 0.45 export const MIN_ENGLISH_SCORE = 0.8
export const MIN_ENGLISH_SUMMARY_SCORE = 0.5
export const LANGUAGE_CHUNK_WORDS = 24
export const LANGUAGE_CHUNK_STRIDE_WORDS = 12
export const MIN_ENGLISH_CHUNK_PERCENTAGE = 0.3
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' }) const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
const characterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) const characterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
@@ -27,30 +42,104 @@ function hasEnoughCharacters(text: string): boolean {
return false return false
} }
function hasEnoughWords(text: string): boolean { function getWords(text: string): string[] {
let wordCount = 0 return [...wordSegmenter.segment(text)]
.filter(({ isWordLike }) => isWordLike)
for (const { isWordLike } of wordSegmenter.segment(text)) { .map(({ segment }) => segment)
if (isWordLike && ++wordCount >= MIN_LANGUAGE_DETECTION_WORDS) return true
}
return false
} }
// TODO, the algorithm can still miss cases where english translation is provided and text primarily in another language function getWordWindows(block: string): string[] {
export function validateEnglishText(text: string): EnglishTextResult { const words = getWords(block)
if (!hasEnoughCharacters(text) || !hasEnoughWords(text)) { if (words.length < MIN_LANGUAGE_DETECTION_WORDS) return []
return { valid: true, detections: [] } if (words.length <= LANGUAGE_CHUNK_WORDS) {
const window = words.join(' ')
return hasEnoughCharacters(window) ? [window] : []
} }
const results = francAll(text) const starts = new Set<number>()
const englishScore = results.find(([language]) => language === 'eng')?.[1] ?? 0 for (
const detections = results.map(([language, accuracy]) => ({ language, accuracy })) let start = 0;
start + MIN_LANGUAGE_DETECTION_WORDS <= words.length;
start += LANGUAGE_CHUNK_STRIDE_WORDS
) {
starts.add(start)
}
starts.add(words.length - LANGUAGE_CHUNK_WORDS)
return [...starts]
.sort((left, right) => left - right)
.map((start) => words.slice(start, start + LANGUAGE_CHUNK_WORDS).join(' '))
.filter(hasEnoughCharacters)
}
export function analyzeLanguageChunks(blocks: string[]): LanguageChunkAnalysis {
let englishChunks = 0
let nonEnglishChunks = 0
let ambiguousChunks = 0
for (const chunk of blocks.flatMap(getWordWindows)) {
const results = francAll(chunk)
const primaryLanguage = results[0]?.[0]
const englishScore = results.find(([language]) => language === 'eng')?.[1] ?? 0
if (primaryLanguage === 'eng') englishChunks++
else if (primaryLanguage && englishScore < MIN_ENGLISH_SCORE) nonEnglishChunks++
else ambiguousChunks++
}
const totalChunks = englishChunks + nonEnglishChunks + ambiguousChunks
const classifiedChunks = englishChunks + nonEnglishChunks
return {
totalChunks,
englishChunks,
nonEnglishChunks,
ambiguousChunks,
englishChunkPercentage: classifiedChunks === 0 ? null : englishChunks / classifiedChunks,
}
}
export function validateEnglishTextBlocks(blocks: string[]): EnglishTextResult {
const normalizedBlocks = blocks.map((block) => block.trim()).filter(Boolean)
const text = normalizedBlocks.join('\n')
const chunkAnalysis = analyzeLanguageChunks(normalizedBlocks)
const valid =
chunkAnalysis.englishChunkPercentage === null ||
chunkAnalysis.englishChunkPercentage >= MIN_ENGLISH_CHUNK_PERCENTAGE
const detections =
getWords(text).length >= MIN_LANGUAGE_DETECTION_WORDS && hasEnoughCharacters(text)
? francAll(text).map(([language, accuracy]) => ({ language, accuracy }))
: []
return { return {
valid: englishScore > MIN_ENGLISH_SCORE, valid,
detections, detections,
reasons: valid ? [] : ['insufficient-english-chunk-coverage'],
} }
} }
export const validateEnglishSummaryText = validateEnglishText export function validateEnglishText(text: string): EnglishTextResult {
return validateEnglishTextBlocks(text.split(/\n+/))
}
export function validateEnglishSummaryText(text: string): EnglishTextResult {
const normalizedText = text.trim()
if (
getWords(normalizedText).length < MIN_LANGUAGE_DETECTION_WORDS ||
!hasEnoughCharacters(normalizedText)
) {
return { valid: true, detections: [], reasons: [] }
}
const detections = francAll(normalizedText).map(([language, accuracy]) => ({
language,
accuracy,
}))
const englishScore = detections.find(({ language }) => language === 'eng')?.accuracy ?? 0
const valid = englishScore >= MIN_ENGLISH_SUMMARY_SCORE
return {
valid,
detections,
reasons: valid ? [] : ['insufficient-english-chunk-coverage'],
}
}
@@ -4,131 +4,143 @@ import test from 'node:test'
import { francAll } from 'franc-min' import { francAll } from 'franc-min'
import { import {
analyzeLanguageChunks,
LANGUAGE_CHUNK_STRIDE_WORDS,
LANGUAGE_CHUNK_WORDS,
MIN_ENGLISH_CHUNK_PERCENTAGE,
MIN_ENGLISH_SCORE, MIN_ENGLISH_SCORE,
MIN_ENGLISH_SUMMARY_SCORE,
MIN_LANGUAGE_DETECTION_CHARACTERS, MIN_LANGUAGE_DETECTION_CHARACTERS,
MIN_LANGUAGE_DETECTION_WORDS, MIN_LANGUAGE_DETECTION_WORDS,
validateEnglishSummaryText, validateEnglishSummaryText,
validateEnglishText, validateEnglishText,
validateEnglishTextBlocks,
} from './index.ts' } from './index.ts'
test('accepts text when franc scores English above the minimum score', () => { const english =
const text = 'This project adds useful tools, configurable settings, and clear documentation for every player.'
'This project adds configurable caves, useful tools, and polished world generation for every player.' const russian =
const result = validateEnglishText(text) 'Этот проект добавляет новые инструменты и значительно улучшает игровой процесс для всех игроков.'
const english = result.detections.find(({ language }) => language === 'eng')
const alternative = result.detections.find(({ language }) => language !== 'eng') test('accepts English text and retains whole-text language diagnostics', () => {
const result = validateEnglishText(english)
const englishDetection = result.detections.find(({ language }) => language === 'eng')
assert.equal(result.valid, true) assert.equal(result.valid, true)
assert.ok(english) assert.ok(englishDetection)
assert.ok(alternative) assert.ok(englishDetection.accuracy > MIN_ENGLISH_SCORE)
assert.ok(english.accuracy > MIN_ENGLISH_SCORE) assert.deepEqual(result.reasons, [])
assert.deepEqual( assert.deepEqual(
result.detections, result.detections,
francAll(text).map(([language, accuracy]) => ({ language, accuracy })), francAll(english).map(([language, accuracy]) => ({ language, accuracy })),
) )
}) })
test('accepts mixed English and Chinese text above the minimum score', () => { test('rejects text containing only confidently non-English chunks', () => {
const result = validateEnglishText(
'A super light QQ bot for minecraft server and QQ group exchange msgs | 超轻量的QQ-MC群服插件',
)
assert.equal(result.valid, true)
})
test('rejects text when franc scores English below the minimum score', () => {
for (const text of [ for (const text of [
'Чистый модпак для комфортной игры с друзьями, новыми заданиями и значительно улучшенной производительностью.', russian,
'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。', 'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。',
'Um modpack focado em desempenho, imersão e exploração, mantendo a experiência próxima ao jogo original.',
]) { ]) {
assert.equal(validateEnglishText(text).valid, false, text) const result = validateEnglishText(text)
assert.equal(result.valid, false, text)
assert.deepEqual(result.reasons, ['insufficient-english-chunk-coverage'])
} }
}) })
test('validates production project text using the English score threshold', () => { test('accepts bilingual text when English chunks are 30% of classified chunks', () => {
const cases = [ const blocks = [
{ english,
text: 'This mod adds some new things about turtles to Minecraft这个模组为Minecraft增加了一些关于乌龟的新东西', english,
valid: true, english,
}, russian,
{ russian,
text: 'Tenhle Project má super mody ktere zlepší kvalitu hraní PVP', russian,
valid: true, russian,
}, russian,
{ russian,
text: '此插件修复了Authme在lophine服务端上的登录漏洞 修复了玩家退出时SQL数据库的Logged依然为1的问题', russian,
valid: false,
},
{
text: 'Мод добавляет рубин — новый драгоценный камень. Добывайте рубиновую руду, кристаллизующуюся в толще камня, а закалив четыре рубина четырьмя незеритовыми ломами - можно будет сделать меч, кирку, броню и крюк захвата',
valid: false,
},
{
text: 'Um modpack Fabric focado em desempenho, imersão e exploração, mantendo a experiência próxima ao Minecraft Vanilla. O objetivo é melhorar o visual, os sons, a geração de mundo e a qualidade de vida do jogo sem adicionar sistemas complexos.',
valid: true,
},
{
text: 'A Create Tacz Warfare Modpack for the Server Create: Warfare',
valid: true,
},
{
text: '一个集成了全息字和占位符创建的插件 A Plugin Integrating Holograms and Placeholder Support',
valid: true,
},
{
text: "A modpack that adds stuff from TaCZ guns, to shaders, to curios slots, and even create! And also, Superb Warfare, in case TaCZ isn't for you!",
valid: true,
},
{
text: 'Leichtes Client-Modpack für entspannte Feierabend-Sessions, bessere Performance, praktische QoL-Mods und ein aufgeräumtes Spielgefühl ohne unnötigen Ballast.',
valid: true,
},
{
text: 'A super light QQ bot for minecraft server and QQ group exchange msgs | 超轻量的QQ-MC群服插件',
valid: true,
},
{
text: 'Integrates MCP into minecraft, made for mapmakers and complex command block logic and datapack making.',
valid: true,
},
] ]
const analysis = analyzeLanguageChunks(blocks)
const result = validateEnglishTextBlocks(blocks)
for (const { text, valid } of cases) { assert.equal(analysis.englishChunks, 3)
assert.equal(validateEnglishText(text).valid, valid, text) assert.equal(analysis.nonEnglishChunks, 7)
} assert.equal(analysis.englishChunkPercentage, 0.3)
assert.equal(MIN_ENGLISH_CHUNK_PERCENTAGE, 0.3)
assert.equal(result.valid, true)
assert.deepEqual(result.reasons, [])
}) })
test('skips language detection for production text below the word minimum', () => { test('rejects mixed-language text when English chunks are below 30% of classified chunks', () => {
for (const text of [ const blocks = [english, english, russian, russian, russian, russian, russian]
'You can chat Gemini AI in Minecraft', const analysis = analyzeLanguageChunks(blocks)
'Create Mods X Zombie Apolcalypse', const result = validateEnglishTextBlocks(blocks)
'Open-world zombie survival modpack',
"BIG-GOOSE Minecraft server's modpack",
]) {
assert.deepEqual(validateEnglishText(text), { valid: true, detections: [] }, text)
}
})
test('skips language detection for text below the character minimum', () => {
const result = validateEnglishText('one two three four a b c d')
assert.deepEqual(result, { valid: true, detections: [] })
assert.equal(MIN_LANGUAGE_DETECTION_CHARACTERS, 35)
})
test('validates production text meeting both signal minimums', () => {
const text = '𝗔𝗶𝗺𝗶𝗻𝗴 𝘁𝗼 𝗲𝗻𝗵𝗮𝗻𝗰𝗲 𝗠𝗶𝗻𝗲𝗰𝗿𝗮𝗳𝘁 𝘄𝗵𝗶𝗹𝗲 𝗿𝗲𝘁𝗮𝗶𝗻𝗶𝗻𝗴 𝘁𝗵𝗮𝘁 𝗩𝗮𝗻𝗶𝗹𝗹𝗮 𝗳𝗲𝗲𝗹!'
const result = validateEnglishText(text)
assert.equal(analysis.englishChunks, 2)
assert.equal(analysis.nonEnglishChunks, 5)
assert.ok((analysis.englishChunkPercentage ?? 0) < MIN_ENGLISH_CHUNK_PERCENTAGE)
assert.equal(result.valid, false) assert.equal(result.valid, false)
assert.ok(result.detections.length > 0) assert.deepEqual(result.reasons, ['insufficient-english-chunk-coverage'])
})
test('uses overlapping 24-word windows with a 12-word stride', () => {
const text = Array.from({ length: 48 }, (_, index) => `word${index}`).join(' ')
const analysis = analyzeLanguageChunks([text])
assert.equal(LANGUAGE_CHUNK_WORDS, 24)
assert.equal(LANGUAGE_CHUNK_STRIDE_WORDS, 12)
assert.equal(analysis.totalChunks, 4)
})
test('skips chunks below the minimum word count', () => {
for (const text of [
'Minecraft',
'Minecraft server',
'This description has only seven English words',
]) {
assert.deepEqual(validateEnglishText(text), { valid: true, detections: [], reasons: [] })
}
assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8) assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8)
}) })
test('uses the same validation for summaries', () => { test('skips chunks below the minimum character count', () => {
assert.equal(validateEnglishSummaryText, validateEnglishText) assert.deepEqual(validateEnglishText('a b c d e f g h'), {
valid: true,
detections: [],
reasons: [],
})
assert.equal(MIN_LANGUAGE_DETECTION_CHARACTERS, 35)
})
test('accepts summaries with an English score of at least 50% without changing description logic', () => {
const text =
'Um modpack Fabric focado em desempenho, imersão e exploração, mantendo a experiência próxima ao Minecraft Vanilla.'
const summaryResult = validateEnglishSummaryText(text)
const englishDetection = summaryResult.detections.find(({ language }) => language === 'eng')
assert.equal(MIN_ENGLISH_SUMMARY_SCORE, 0.5)
assert.ok(englishDetection)
assert.ok(englishDetection.accuracy >= MIN_ENGLISH_SUMMARY_SCORE)
assert.equal(summaryResult.valid, true)
assert.equal(validateEnglishText(text).valid, false)
})
test('skips summary detection below its word or character minimum', () => {
for (const text of ['This summary has only seven English words', 'a b c d e f g h']) {
assert.deepEqual(validateEnglishSummaryText(text), {
valid: true,
detections: [],
reasons: [],
})
}
assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8)
assert.equal(MIN_LANGUAGE_DETECTION_CHARACTERS, 35)
}) })
test('allows empty text to be handled by required-field validation', () => { test('allows empty text to be handled by required-field validation', () => {
assert.deepEqual(validateEnglishText(' '), { valid: true, detections: [] }) assert.deepEqual(validateEnglishText(' '), { valid: true, detections: [], reasons: [] })
}) })