feat: start language detection for summary and desc

This commit is contained in:
tdgao
2026-08-28 18:03:19 -06:00
parent df58a2e2f9
commit 3b0052eeda
9 changed files with 340 additions and 237 deletions
+2 -1
View File
@@ -12,9 +12,10 @@
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope packages/moderation"
},
"dependencies": {
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/api-client": "workspace:*",
"franc-min": "^6.2.0",
"linkify-it": "^5.0.0",
"node-html-parser": "^9.0.1",
"obscenity": "^0.4.6",
+1
View File
@@ -19,6 +19,7 @@ export * from './types/reports'
export * from './types/settings'
export * from './utils'
export * from './validation-rules/index.ts'
export * from './validators/language'
export * from './validators/links'
export * from './validators/non-standard-text'
export * from './validators/profanity'
@@ -14,10 +14,12 @@ import {
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateEnglishText,
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
projectRequiresEnglishText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
@@ -64,6 +66,11 @@ const messages = defineMessages({
id: 'nags.project-description-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
nonEnglish: {
id: 'nags.project-description-non-english.description',
defaultMessage:
'Your project description must be written in English or include an English translation.',
},
bannedLink: {
id: 'nags.project-description-banned-link.description',
defaultMessage: '“{fullUrl}” is not allowed in project descriptions.',
@@ -238,6 +245,21 @@ export const projectDescriptionValidationRules = {
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'project-description-non-english': {
severity: 'warning',
evaluate: (description) => {
const text = extractDescriptionText(description ?? '')
if (text.length < MIN_DESCRIPTION_CHARS || !validateSpam(text).valid) {
return { valid: true }
}
return evaluateEnglishText(text)
},
presentation: {
message: messages.nonEnglish,
nag: { title: messages.fixDescription, ...commonNagPresentation },
},
},
'add-description': {
severity: 'error',
evaluate: (description) => ({
@@ -323,5 +345,10 @@ export function validateProjectDescription(
}
export function getDescriptionNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
return toNags(evaluateRules(context.projectV3.description, projectDescriptionValidationRules))
const matches = evaluateRules(context.projectV3.description, projectDescriptionValidationRules)
return toNags(
projectRequiresEnglishText(context.projectV3)
? matches
: matches.filter(({ code }) => code !== 'project-description-non-english'),
)
}
@@ -7,10 +7,12 @@ import type { Nag, ProjectValidationContext } from '../../types/nags.ts'
import { validateSpam } from '../../validators/spam/index.ts'
import { evaluateRules } from '../evaluate-rules.ts'
import {
evaluateEnglishSummaryText,
evaluateNonStandardText,
evaluateProfanity,
evaluateSlur,
normalizeProjectFieldText,
projectRequiresEnglishText,
} from '../text.ts'
import { toFieldMessages } from '../to-field-messages.ts'
import { toNags } from '../to-nags.ts'
@@ -57,6 +59,11 @@ const messages = defineMessages({
id: 'nags.project-summary-non-standard-text.description',
defaultMessage: 'Non-standard text characters, such as “₮ɆӾ₮”, are not allowed.',
},
nonEnglish: {
id: 'nags.project-summary-non-english.description',
defaultMessage:
'Your project summary must be written in English or include an English translation.',
},
matchesName: {
id: 'project.text-validation.summary-matches-title',
defaultMessage: "A project summary cannot be the same as it's title.",
@@ -147,6 +154,25 @@ export const projectSummaryValidationRules = {
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-non-english': {
severity: 'warning',
evaluate: ({ summary }) => {
const normalized = normalizeProjectFieldText(summary ?? '')
if (
!normalized ||
normalized.length < MIN_SUMMARY_CHARS ||
containsProjectSummaryLinkOrIp(normalized) ||
!validateSpam(normalized).valid
) {
return { valid: true }
}
return evaluateEnglishSummaryText(normalized)
},
presentation: {
message: messages.nonEnglish,
nag: { title: messages.fixSummary, ...commonNagPresentation },
},
},
'project-summary-matches-title': {
severity: 'error',
evaluate: ({ summary, name }) => ({
@@ -214,10 +240,13 @@ export function validateProjectSummary(
}
export function getSummaryNags(context: Pick<ProjectValidationContext, 'projectV3'>): Nag[] {
const matches = evaluateRules(
{ summary: context.projectV3.summary, name: context.projectV3.name },
projectSummaryValidationRules,
)
return toNags(
evaluateRules(
{ summary: context.projectV3.summary, name: context.projectV3.name },
projectSummaryValidationRules,
),
projectRequiresEnglishText(context.projectV3)
? matches
: matches.filter(({ code }) => code !== 'project-summary-non-english'),
)
}
@@ -135,6 +135,17 @@ test('validates summary content from one rule set', () => {
)
})
test('warns when a project summary is mostly non-English', () => {
assert.deepEqual(
validateProjectSummary({
summary:
'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。',
name: 'Project title',
}).map(({ code, severity }) => ({ code, severity })),
[{ code: 'project-summary-non-english', severity: 'warning' }],
)
})
test('rejects repeated summary padding', () => {
assert.deepEqual(
validateProjectSummary({
@@ -224,6 +235,22 @@ test('validates description requirements and simultaneous recommendations', () =
)
})
test('warns when a project description is mostly non-English', () => {
const description = [
'このプロジェクトは設定可能な洞窟と新しい構造物を世界生成に追加します。',
'プレイヤーは設定ファイルを使って、それぞれの機能を個別に変更できます。',
'探索をより楽しくする便利な道具や新しい報酬もたくさん含まれています。',
'サーバーとクライアントの両方で快適に動作するように設計されています。',
].join(' ')
assert.deepEqual(
validateProjectDescription(description)
.filter(({ code }) => code === 'project-description-non-english')
.map(({ code, severity }) => ({ code, severity })),
[{ code: 'project-description-non-english', severity: 'warning' }],
)
})
test('allows short headers regardless of punctuation', () => {
assert.deepEqual(analyzeHeaderLength('# Version 1.2 is available'), {
hasLongHeaders: false,
@@ -1,3 +1,5 @@
import type { ProjectValidationContext } from '../types/nags.ts'
import { validateEnglishSummaryText, validateEnglishText } from '../validators/language/index.ts'
import {
getNonStandardTextRatio,
validateNonStandardText,
@@ -9,6 +11,17 @@ export function normalizeProjectFieldText(value: string) {
return value.trim().normalize('NFC')
}
export function projectRequiresEnglishText(
project: Pick<
ProjectValidationContext['projectV3'],
'minecraft_java_server' | 'minecraft_server'
>,
) {
return (
!project.minecraft_java_server || project.minecraft_server?.languages?.includes('en') === true
)
}
export function evaluateSlur(text: string): ValidationRuleEvaluation {
const match = validateProfanity(text).matches.find((match) => match.kind === 'slur')
return match ? { valid: false, values: { value: match.rawText } } : { valid: true }
@@ -34,3 +47,13 @@ export function evaluateNonStandardText(
valid: result.valid || getNonStandardTextRatio(text, result) < failureThreshold,
}
}
export function evaluateEnglishText(text: string): ValidationRuleEvaluation {
const result = validateEnglishText(text)
return { valid: result.valid }
}
export function evaluateEnglishSummaryText(text: string): ValidationRuleEvaluation {
const result = validateEnglishSummaryText(text)
return { valid: result.valid }
}
@@ -0,0 +1,55 @@
import { francAll } from 'franc-min'
export interface LanguageDetection {
language: string
accuracy: number
}
export interface EnglishTextResult {
valid: boolean
detections: LanguageDetection[]
}
export const MIN_LANGUAGE_DETECTION_WORDS = 8
export const MIN_LANGUAGE_DETECTION_CHARACTERS = 35
export const MIN_ENGLISH_SCORE = 0.45
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
const characterSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
function hasEnoughCharacters(text: string): boolean {
let characterCount = 0
for (const _ of characterSegmenter.segment(text.trim())) {
if (++characterCount >= MIN_LANGUAGE_DETECTION_CHARACTERS) return true
}
return false
}
function hasEnoughWords(text: string): boolean {
let wordCount = 0
for (const { isWordLike } of wordSegmenter.segment(text)) {
if (isWordLike && ++wordCount >= MIN_LANGUAGE_DETECTION_WORDS) return true
}
return false
}
export function validateEnglishText(text: string): EnglishTextResult {
if (!hasEnoughCharacters(text) || !hasEnoughWords(text)) {
return { valid: true, detections: [] }
}
const results = francAll(text)
const englishScore = results.find(([language]) => language === 'eng')?.[1] ?? 0
const detections = results.map(([language, accuracy]) => ({ language, accuracy }))
return {
valid: englishScore > MIN_ENGLISH_SCORE,
detections,
}
}
export const validateEnglishSummaryText = validateEnglishText
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { francAll } from 'franc-min'
import {
MIN_ENGLISH_SCORE,
MIN_LANGUAGE_DETECTION_CHARACTERS,
MIN_LANGUAGE_DETECTION_WORDS,
validateEnglishSummaryText,
validateEnglishText,
} from './index.ts'
test('accepts text when franc scores English above the minimum score', () => {
const text =
'This project adds configurable caves, useful tools, and polished world generation for every player.'
const result = validateEnglishText(text)
const english = result.detections.find(({ language }) => language === 'eng')
const alternative = result.detections.find(({ language }) => language !== 'eng')
assert.equal(result.valid, true)
assert.ok(english)
assert.ok(alternative)
assert.ok(english.accuracy > MIN_ENGLISH_SCORE)
assert.deepEqual(
result.detections,
francAll(text).map(([language, accuracy]) => ({ language, accuracy })),
)
})
test('accepts mixed English and Chinese text above the minimum score', () => {
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 [
'Чистый модпак для комфортной игры с друзьями, новыми заданиями и значительно улучшенной производительностью.',
'これは新しい洞窟と構造物を追加し、すべてのプレイヤーの世界生成を改善するプロジェクトです。',
]) {
assert.equal(validateEnglishText(text).valid, false, text)
}
})
test('validates production project text using the English score threshold', () => {
const cases = [
{
text: 'This mod adds some new things about turtles to Minecraft这个模组为Minecraft增加了一些关于乌龟的新东西',
valid: true,
},
{
text: 'Tenhle Project má super mody ktere zlepší kvalitu hraní PVP',
valid: true,
},
{
text: '此插件修复了Authme在lophine服务端上的登录漏洞 修复了玩家退出时SQL数据库的Logged依然为1的问题',
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,
},
]
for (const { text, valid } of cases) {
assert.equal(validateEnglishText(text).valid, valid, text)
}
})
test('skips language detection for production text below the word minimum', () => {
for (const text of [
'You can chat Gemini AI in Minecraft',
'Create Mods X Zombie Apolcalypse',
'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(result.valid, false)
assert.ok(result.detections.length > 0)
assert.equal(MIN_LANGUAGE_DETECTION_WORDS, 8)
})
test('uses the same validation for summaries', () => {
assert.equal(validateEnglishSummaryText, validateEnglishText)
})
test('allows empty text to be handled by required-field validation', () => {
assert.deepEqual(validateEnglishText(' '), { valid: true, detections: [] })
})