openvibers/openvibe.live Commits
Arena: stop false beefs and phantom subject moments
Changes
@@@ -389,9 +389,11 @@ function activeTopicFor(userId) {}/** A judged on-mic chunk about a topic: { on_topic, quality, best_line, about, flagged } → judged moment + XP. */++const MIN_JUDGED_QUALITY = 4;function applyTopicJudgement(userId, topic, judgement, lineRef) {if (judgement.flagged || !judgement.on_topic) return { applied: false };const quality = Math.max(0, Math.min(10, Number(judgement.quality) || 0));++ if (quality < MIN_JUDGED_QUALITY) return { applied: false, reason: 'too weak' };const user = db.getUserById(userId);const m = addMoment(topic.id, { kind: 'speech', source: 'judge', userId, username: user?.username || null, streamId: lineRef?.stream_id || null, vodId: lineRef?.vod_id || null, sec: lineRef?.sec ?? null, text: judgement.best_line || judgement.about || 'talked on it', quality, about: judgement.about || null });if (!m) return { applied: false };@@@ -729,7 +731,7 @@ function topicView(topic, roster, { detail = false } = {}) {};if (detail) {out.submitted_text = topic.submitted_text || null;-- out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80', [topic.id]).map(momentView);++ out.moments = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND (quality IS NULL OR quality >= ${MIN_JUDGED_QUALITY}) ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80`, [topic.id]).map(momentView);out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }@@@ -89,7 +89,7 @@ const BEEF_SCHEMA = {},},};--const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;++const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. DEFAULT TO about_target=false: gameplay chatter, mumbling and generic sentences are NOT about them even if their name was said a minute ago; if how_the_name_was_matched is not "exact" (a sound-alike), be extra strict. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;const TOPIC_SCHEMA = {name: 'arena_topic_judgement',@@@ -105,7 +105,7 @@ const TOPIC_SCHEMA = {},},};--const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. Decide whether the speech is really about the subject (shit talk, a take, a rant, a bit, dunking, defending — all count), score its quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Ordinary gameplay chatter or unrelated talk → on_topic false. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;++const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. DEFAULT TO on_topic=false. Say true ONLY if a stranger reading the speech alone would say "they are clearly talking about that subject" — it names the subject, a keyword, or unmistakably continues it. Gameplay chatter, mumbling, "I'm not sure", "I'm going to the store", reading chat, generic talk → false, quality 0. When true, score the quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Forgive transcription noise but never invent a connection. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;function heuristicBeef(text, targetNames, { named = true } = {}) {const t = text.toLowerCase();@@@ -132,7 +132,7 @@ function heuristicTopic(text, topic) {return { on_topic: hits > 0, quality, best_line: ((named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text).trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };}--async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true } = {}) {++async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };const tf = roster.byId[targetId];const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);@@@ -141,7 +141,7 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, naif (aiOn()) {try {const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM,-- user: JSON.stringify({ target_names: targetNames, target_as_transcribed: spokenForms, target_named_in_new_speech: named, what_speaker_already_said_about_target: context || null, new_speech: text }),++ user: JSON.stringify({ target_names: targetNames, target_as_transcribed: spokenForms, target_named_in_new_speech: named, how_the_name_was_matched: how, what_speaker_already_said_about_target: context || null, new_speech: text }),json: BEEF_SCHEMA, maxTokens: 240, temperature: 0.4, timeoutMs: 25000 });if (r && r.json && typeof r.json.quality === 'number') j = r.json;} catch (e) { console.warn('[Arena] beef judge:', e.message); }@@@ -195,6 +195,7 @@ const FOCUS_TAIL_MS = 2 * 60 * 1000; // how long a bare name-drop keeps theconst FOCUS_EXTEND_MS = 3 * 60 * 1000; // every hit while locked extends the lock by thisconst FOCUS_MAX_MS = 20 * 60 * 1000; // hard cap without a fresh name-dropconst FOCUS_MISSES_TO_DROP = 2;++const SUBJECT_TAIL_MS = 2 * 60 * 1000; // a subject keyword said on mic keeps the subject judge on it this long (hits extend)function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }@@@ -205,9 +206,11 @@ async function judgeFocus(stream, roster, st, events, { reason }) {const lines = f.lines; f.lines = [];const named = lines.some(l => l.named);st.lastJudgeAt = Date.now();-- const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named });++ const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });const now = Date.now();-- if (j.aimed_at_target) {++ // A hit must actually be a hit: exact name-drops need quality ≥ 3, sound-alike matches ≥ 5 before anything opens.++ const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);++ if (j.aimed_at_target && j.quality >= minQ) {const ref = lineRefFor(lines, j.best_line);const res = beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);@@@ -249,7 +252,7 @@ async function tickStream(stream, roster, events) {for (const t of board().matchTopics(line.t)) {const m = board().noteMicMention(t.id, { userId: stream.user_id, username: roster.byId[stream.user_id]?.user?.username || null, streamId: stream.id, vodId: line.v, sec: Math.max(0, line.s - 2), text: line.t });if (m) events.push({ kind: 'topic_mention', streamId: stream.id, speakerId: stream.user_id, topicId: t.id });-- st.lastTopic = { id: t.id, at: now };++ st.lastTopic = { id: t.id, at: now, lockUntil: now + SUBJECT_TAIL_MS, misses: 0 };}} catch (e) { console.warn('[Arena] topic match:', e.message); }const mentions = mentionsDetailed(line.t, stream.user_id, roster);@@@ -281,9 +284,10 @@ async function tickStream(stream, roster, events) {return; // one judge call per stream per tick}-- // 2) Board subject: the one they chose, else the one they just brought up on mic.-- let topic = board().activeTopicFor(stream.user_id);-- if (!topic && st.lastTopic && now - st.lastTopic.at < 3 * 60 * 1000) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);++ // 2) Board subject: only while a subject keyword was actually said recently (the lock) — never "whatever they touched once".++ let topic = null;++ if (st.lastTopic && now < (st.lastTopic.lockUntil || 0)) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);++ if (!topic && st.topic.lines.length > 40) st.topic.lines = st.topic.lines.slice(-20);if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {const lines = st.topic.lines; st.topic.lines = [];const text = bufferText(lines);@@@ -291,10 +295,10 @@ async function tickStream(stream, roster, events) {const j = await judgeTopic(stream.user_id, topic, text);const ref = { ...lineRefFor(lines, j.best_line), stream_id: stream.id };const res = board().applyTopicJudgement(stream.user_id, topic, j, ref);++ if (res.applied) { st.lastTopic.lockUntil = Math.max(st.lastTopic.lockUntil || 0, Date.now() + SUBJECT_TAIL_MS); st.lastTopic.misses = 0; }++ else if (++st.lastTopic.misses >= 2) st.lastTopic.lockUntil = 0; // moved onst.lastTopicJudgement = { at: new Date().toISOString(), topic_id: topic.id, topic: topic.text, ...j, applied: res.applied, xp: res.xp || 0 };events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });-- } else if (!topic && st.topic.lines.length > 80) {-- st.topic.lines = st.topic.lines.slice(-40);}}@@@ -16,7 +16,8 @@*/'use strict';--const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris'++const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '++ + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone').split(/\s+/));const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;@@@ -38,7 +39,7 @@ function splitHandle(name) {return s.toLowerCase().split(/\s+/).filter(Boolean);}--function deleet(w) { return String(w).replace(/[0134578@$!|]/g, c => LEET[c] || c); }++function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }/*** All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).@@@ -55,6 +56,8 @@ function variants(name) {const base = splitHandle(raw);add(base);add(base.map(deleet));++ // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.++ if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"add(base.map(deleet).filter(w => !/^\d+$/.test(w)));const noSuffix = raw.replace(SUFFIXES, '');@@@ -138,7 +141,8 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;const n = e.words.length;const target = e.letters;-- if (target.length < 5) continue; // too short to fuzzy-match safely++ if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely++ if (n === 1 && COMMON.has(e.name)) continue;const maxD = ratioThreshold(target.length);// n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {@@@ -148,9 +152,10 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}if (Math.abs(glued.length - target.length) > maxD + 4) continue;// Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~// "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).++ if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }// "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl-- if (glued.length >= 5 && !gram.every(w => COMMON.has(w)) && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }++ if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }}}}@@@ -256,6 +256,13 @@ board.ensureTables(); beef.ensureTables();assert.deepStrictEqual(hit('goose lee ducked'), ['3:phonetic']);assert.deepStrictEqual(hit('Goosley never answered'), ['3:phonetic']);assert.deepStrictEqual(hit('that goose flew'), [], 'a prefix alone is not the name');++ const S = [...names.aliasEntries(9, ['Mater151']), ...names.aliasEntries(10, ['brunomelato'])];++ const hit2 = (t) => names.findMentions(t, S).map(m => `${m.userId}:${m.how}`);++ assert.deepStrictEqual(hit2('it doesnt matter anymore'), [], 'short names never sound-match plain words');++ assert.deepStrictEqual(hit2('the water is cold'), []); assert.deepStrictEqual(hit2('see you later'), []);++ assert.deepStrictEqual(hit2('mater is trash'), ['9:exact'], 'short names still match exactly');++ assert.deepStrictEqual(hit2('bruno melato is a bot'), ['10:phonetic'], 'a glued handle split by the transcriber');++ assert.deepStrictEqual(hit2('a bruno at the door'), [], 'half a handle is not the handle');console.log('✅ name matching: camelCase/snake/digits/leet/suffixes, fuzzy + phonetic, guarded');const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);
@@ -389,9 +389,11 @@ function activeTopicFor(userId) {
@@ -389,9 +389,11 @@ function activeTopicFor(userId) {
}
}
/** A judged on-mic chunk about a topic: { on_topic, quality, best_line, about, flagged } → judged moment + XP. */
/** A judged on-mic chunk about a topic: { on_topic, quality, best_line, about, flagged } → judged moment + XP. */
+const MIN_JUDGED_QUALITY = 4;
function applyTopicJudgement(userId, topic, judgement, lineRef) {
function applyTopicJudgement(userId, topic, judgement, lineRef) {
if (judgement.flagged || !judgement.on_topic) return { applied: false };
if (judgement.flagged || !judgement.on_topic) return { applied: false };
const quality = Math.max(0, Math.min(10, Number(judgement.quality) || 0));
const quality = Math.max(0, Math.min(10, Number(judgement.quality) || 0));
+ if (quality < MIN_JUDGED_QUALITY) return { applied: false, reason: 'too weak' };
const user = db.getUserById(userId);
const user = db.getUserById(userId);
const m = addMoment(topic.id, { kind: 'speech', source: 'judge', userId, username: user?.username || null, streamId: lineRef?.stream_id || null, vodId: lineRef?.vod_id || null, sec: lineRef?.sec ?? null, text: judgement.best_line || judgement.about || 'talked on it', quality, about: judgement.about || null });
const m = addMoment(topic.id, { kind: 'speech', source: 'judge', userId, username: user?.username || null, streamId: lineRef?.stream_id || null, vodId: lineRef?.vod_id || null, sec: lineRef?.sec ?? null, text: judgement.best_line || judgement.about || 'talked on it', quality, about: judgement.about || null });
if (!m) return { applied: false };
if (!m) return { applied: false };
@@ -729,7 +731,7 @@ function topicView(topic, roster, { detail = false } = {}) {
@@ -729,7 +731,7 @@ function topicView(topic, roster, { detail = false } = {}) {
};
};
if (detail) {
if (detail) {
out.submitted_text = topic.submitted_text || null;
out.submitted_text = topic.submitted_text || null;
- out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80', [topic.id]).map(momentView);
+ out.moments = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND (quality IS NULL OR quality >= ${MIN_JUDGED_QUALITY}) ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80`, [topic.id]).map(momentView);
out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });
out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });
out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);
out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);
for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }
for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }
@@ -89,7 +89,7 @@ const BEEF_SCHEMA = {
@@ -89,7 +89,7 @@ const BEEF_SCHEMA = {
},
},
},
},
};
};
-const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
+const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. DEFAULT TO about_target=false: gameplay chatter, mumbling and generic sentences are NOT about them even if their name was said a minute ago; if how_the_name_was_matched is not "exact" (a sound-alike), be extra strict. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
const TOPIC_SCHEMA = {
const TOPIC_SCHEMA = {
name: 'arena_topic_judgement',
name: 'arena_topic_judgement',
@@ -105,7 +105,7 @@ const TOPIC_SCHEMA = {
@@ -105,7 +105,7 @@ const TOPIC_SCHEMA = {
},
},
},
},
};
};
-const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. Decide whether the speech is really about the subject (shit talk, a take, a rant, a bit, dunking, defending — all count), score its quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Ordinary gameplay chatter or unrelated talk → on_topic false. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;
+const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. DEFAULT TO on_topic=false. Say true ONLY if a stranger reading the speech alone would say "they are clearly talking about that subject" — it names the subject, a keyword, or unmistakably continues it. Gameplay chatter, mumbling, "I'm not sure", "I'm going to the store", reading chat, generic talk → false, quality 0. When true, score the quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Forgive transcription noise but never invent a connection. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;
function heuristicBeef(text, targetNames, { named = true } = {}) {
function heuristicBeef(text, targetNames, { named = true } = {}) {
const t = text.toLowerCase();
const t = text.toLowerCase();
@@ -132,7 +132,7 @@ function heuristicTopic(text, topic) {
@@ -132,7 +132,7 @@ function heuristicTopic(text, topic) {
return { on_topic: hits > 0, quality, best_line: ((named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text).trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
return { on_topic: hits > 0, quality, best_line: ((named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text).trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
}
}
-async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true } = {}) {
+async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {
if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
const tf = roster.byId[targetId];
const tf = roster.byId[targetId];
const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);
const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);
@@ -141,7 +141,7 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na
@@ -141,7 +141,7 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na
if (aiOn()) {
if (aiOn()) {
try {
try {
const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM,
const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM,
- user: JSON.stringify({ target_names: targetNames, target_as_transcribed: spokenForms, target_named_in_new_speech: named, what_speaker_already_said_about_target: context || null, new_speech: text }),
+ user: JSON.stringify({ target_names: targetNames, target_as_transcribed: spokenForms, target_named_in_new_speech: named, how_the_name_was_matched: how, what_speaker_already_said_about_target: context || null, new_speech: text }),
json: BEEF_SCHEMA, maxTokens: 240, temperature: 0.4, timeoutMs: 25000 });
json: BEEF_SCHEMA, maxTokens: 240, temperature: 0.4, timeoutMs: 25000 });
if (r && r.json && typeof r.json.quality === 'number') j = r.json;
if (r && r.json && typeof r.json.quality === 'number') j = r.json;
} catch (e) { console.warn('[Arena] beef judge:', e.message); }
} catch (e) { console.warn('[Arena] beef judge:', e.message); }
@@ -195,6 +195,7 @@ const FOCUS_TAIL_MS = 2 * 60 * 1000; // how long a bare name-drop keeps the
@@ -195,6 +195,7 @@ const FOCUS_TAIL_MS = 2 * 60 * 1000; // how long a bare name-drop keeps the
const FOCUS_EXTEND_MS = 3 * 60 * 1000; // every hit while locked extends the lock by this
const FOCUS_EXTEND_MS = 3 * 60 * 1000; // every hit while locked extends the lock by this
const FOCUS_MAX_MS = 20 * 60 * 1000; // hard cap without a fresh name-drop
const FOCUS_MAX_MS = 20 * 60 * 1000; // hard cap without a fresh name-drop
const FOCUS_MISSES_TO_DROP = 2;
const FOCUS_MISSES_TO_DROP = 2;
+const SUBJECT_TAIL_MS = 2 * 60 * 1000; // a subject keyword said on mic keeps the subject judge on it this long (hits extend)
function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }
function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }
@@ -205,9 +206,11 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
@@ -205,9 +206,11 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
const lines = f.lines; f.lines = [];
const lines = f.lines; f.lines = [];
const named = lines.some(l => l.named);
const named = lines.some(l => l.named);
st.lastJudgeAt = Date.now();
st.lastJudgeAt = Date.now();
- const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named });
+ const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });
const now = Date.now();
const now = Date.now();
- if (j.aimed_at_target) {
+ // A hit must actually be a hit: exact name-drops need quality ≥ 3, sound-alike matches ≥ 5 before anything opens.
+ const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);
+ if (j.aimed_at_target && j.quality >= minQ) {
const ref = lineRefFor(lines, j.best_line);
const ref = lineRefFor(lines, j.best_line);
const res = beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });
const res = beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });
f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
@@ -249,7 +252,7 @@ async function tickStream(stream, roster, events) {
@@ -249,7 +252,7 @@ async function tickStream(stream, roster, events) {
for (const t of board().matchTopics(line.t)) {
for (const t of board().matchTopics(line.t)) {
const m = board().noteMicMention(t.id, { userId: stream.user_id, username: roster.byId[stream.user_id]?.user?.username || null, streamId: stream.id, vodId: line.v, sec: Math.max(0, line.s - 2), text: line.t });
const m = board().noteMicMention(t.id, { userId: stream.user_id, username: roster.byId[stream.user_id]?.user?.username || null, streamId: stream.id, vodId: line.v, sec: Math.max(0, line.s - 2), text: line.t });
if (m) events.push({ kind: 'topic_mention', streamId: stream.id, speakerId: stream.user_id, topicId: t.id });
if (m) events.push({ kind: 'topic_mention', streamId: stream.id, speakerId: stream.user_id, topicId: t.id });
- st.lastTopic = { id: t.id, at: now };
+ st.lastTopic = { id: t.id, at: now, lockUntil: now + SUBJECT_TAIL_MS, misses: 0 };
}
}
} catch (e) { console.warn('[Arena] topic match:', e.message); }
} catch (e) { console.warn('[Arena] topic match:', e.message); }
const mentions = mentionsDetailed(line.t, stream.user_id, roster);
const mentions = mentionsDetailed(line.t, stream.user_id, roster);
@@ -281,9 +284,10 @@ async function tickStream(stream, roster, events) {
@@ -281,9 +284,10 @@ async function tickStream(stream, roster, events) {
return; // one judge call per stream per tick
return; // one judge call per stream per tick
}
}
- // 2) Board subject: the one they chose, else the one they just brought up on mic.
- let topic = board().activeTopicFor(stream.user_id);
- if (!topic && st.lastTopic && now - st.lastTopic.at < 3 * 60 * 1000) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);
+ // 2) Board subject: only while a subject keyword was actually said recently (the lock) — never "whatever they touched once".
+ let topic = null;
+ if (st.lastTopic && now < (st.lastTopic.lockUntil || 0)) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);
+ if (!topic && st.topic.lines.length > 40) st.topic.lines = st.topic.lines.slice(-20);
if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
const lines = st.topic.lines; st.topic.lines = [];
const lines = st.topic.lines; st.topic.lines = [];
const text = bufferText(lines);
const text = bufferText(lines);
@@ -291,10 +295,10 @@ async function tickStream(stream, roster, events) {
@@ -291,10 +295,10 @@ async function tickStream(stream, roster, events) {
const j = await judgeTopic(stream.user_id, topic, text);
const j = await judgeTopic(stream.user_id, topic, text);
const ref = { ...lineRefFor(lines, j.best_line), stream_id: stream.id };
const ref = { ...lineRefFor(lines, j.best_line), stream_id: stream.id };
const res = board().applyTopicJudgement(stream.user_id, topic, j, ref);
const res = board().applyTopicJudgement(stream.user_id, topic, j, ref);
+ if (res.applied) { st.lastTopic.lockUntil = Math.max(st.lastTopic.lockUntil || 0, Date.now() + SUBJECT_TAIL_MS); st.lastTopic.misses = 0; }
+ else if (++st.lastTopic.misses >= 2) st.lastTopic.lockUntil = 0; // moved on
st.lastTopicJudgement = { at: new Date().toISOString(), topic_id: topic.id, topic: topic.text, ...j, applied: res.applied, xp: res.xp || 0 };
st.lastTopicJudgement = { at: new Date().toISOString(), topic_id: topic.id, topic: topic.text, ...j, applied: res.applied, xp: res.xp || 0 };
events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });
events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });
- } else if (!topic && st.topic.lines.length > 80) {
- st.topic.lines = st.topic.lines.slice(-40);
}
}
}
}
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
*/
*/
'use strict';
'use strict';
-const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris'
+const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '
+ + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone'
).split(/\s+/));
).split(/\s+/));
const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;
const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;
@@ -38,7 +39,7 @@ function splitHandle(name) {
@@ -38,7 +39,7 @@ function splitHandle(name) {
return s.toLowerCase().split(/\s+/).filter(Boolean);
return s.toLowerCase().split(/\s+/).filter(Boolean);
}
}
-function deleet(w) { return String(w).replace(/[0134578@$!|]/g, c => LEET[c] || c); }
+function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }
/**
/**
* All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).
* All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).
@@ -55,6 +56,8 @@ function variants(name) {
@@ -55,6 +56,8 @@ function variants(name) {
const base = splitHandle(raw);
const base = splitHandle(raw);
add(base);
add(base);
add(base.map(deleet));
add(base.map(deleet));
+ // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.
+ if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }
add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"
add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"
add(base.map(deleet).filter(w => !/^\d+$/.test(w)));
add(base.map(deleet).filter(w => !/^\d+$/.test(w)));
const noSuffix = raw.replace(SUFFIXES, '');
const noSuffix = raw.replace(SUFFIXES, '');
@@ -138,7 +141,8 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
@@ -138,7 +141,8 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;
if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;
const n = e.words.length;
const n = e.words.length;
const target = e.letters;
const target = e.letters;
- if (target.length < 5) continue; // too short to fuzzy-match safely
+ if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely
+ if (n === 1 && COMMON.has(e.name)) continue;
const maxD = ratioThreshold(target.length);
const maxD = ratioThreshold(target.length);
// n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)
// n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)
for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {
for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {
@@ -148,9 +152,10 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
@@ -148,9 +152,10 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
if (Math.abs(glued.length - target.length) > maxD + 4) continue;
if (Math.abs(glued.length - target.length) > maxD + 4) continue;
// Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~
// Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~
// "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).
// "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).
+ if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)
if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }
if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }
// "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl
// "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl
- if (glued.length >= 5 && !gram.every(w => COMMON.has(w)) && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }
+ if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }
}
}
}
}
}
}
@@ -256,6 +256,13 @@ board.ensureTables(); beef.ensureTables();
@@ -256,6 +256,13 @@ board.ensureTables(); beef.ensureTables();
assert.deepStrictEqual(hit('goose lee ducked'), ['3:phonetic']);
assert.deepStrictEqual(hit('goose lee ducked'), ['3:phonetic']);
assert.deepStrictEqual(hit('Goosley never answered'), ['3:phonetic']);
assert.deepStrictEqual(hit('Goosley never answered'), ['3:phonetic']);
assert.deepStrictEqual(hit('that goose flew'), [], 'a prefix alone is not the name');
assert.deepStrictEqual(hit('that goose flew'), [], 'a prefix alone is not the name');
+ const S = [...names.aliasEntries(9, ['Mater151']), ...names.aliasEntries(10, ['brunomelato'])];
+ const hit2 = (t) => names.findMentions(t, S).map(m => `${m.userId}:${m.how}`);
+ assert.deepStrictEqual(hit2('it doesnt matter anymore'), [], 'short names never sound-match plain words');
+ assert.deepStrictEqual(hit2('the water is cold'), []); assert.deepStrictEqual(hit2('see you later'), []);
+ assert.deepStrictEqual(hit2('mater is trash'), ['9:exact'], 'short names still match exactly');
+ assert.deepStrictEqual(hit2('bruno melato is a bot'), ['10:phonetic'], 'a glued handle split by the transcriber');
+ assert.deepStrictEqual(hit2('a bruno at the door'), [], 'half a handle is not the handle');
console.log('✅ name matching: camelCase/snake/digits/leet/suffixes, fuzzy + phonetic, guarded');
console.log('✅ name matching: camelCase/snake/digits/leet/suffixes, fuzzy + phonetic, guarded');
const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);
const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);
assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);
assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);