mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
fix(tokenizer): exclude unparsed-run tokens from annotations and N+1 (#153)
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
type: fixed
|
type: changed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
|
- New reading-aware subtitle parsing and known-word matching: the known-word cache now stores each Anki card's word together with its reading (cache format v3), and a token only gets the known-word highlight when its parsed reading agrees with the card. The cache and the stats server upgrade automatically.
|
||||||
|
- Fixes words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" no longer matches a known 床/ゆか "floor" card). Cards without a reading field keep matching in any reading as before.
|
||||||
|
- Single-kana grammar tokens (よ in 全然いいよ, standalone え) no longer borrow the reading of an unrelated card (such as 夜 or 絵) and get painted as known: reading-only matching requires at least two kana, while single-kana cards still match by their word field.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
type: fixed
|
type: changed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Fixed single-kana grammar tokens counting as known words by borrowing the reading of an unrelated Anki note (よ in 全然いいよ matched a card read よ such as 夜, standalone え matched 絵), which painted them with the known-word highlight. Reading-only known-word matching now requires at least two kana; single-kana cards still match by their word field, and tokens genuinely present in the known-words cache (e.g. です) keep their highlight.
|
|
||||||
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
|
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Session known-word counts no longer show 0 everywhere. The stats server's known-word cache parser only understood the v1/v2 cache formats, so after the reading-aware v3 cache upgrade it silently treated the cache as missing; it now flattens v3 note entries into the headword set.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Fixed subtitle text that Yomitan's parser cannot match (e.g. the truncated volitional in とこ戻ろ…) being rendered as plain, non-interactive text: it was invisible to hover/lookup and excluded from the n+1 word count, which could wrongly mark a sentence as n+1. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation; bracketed SFX/speaker captions and punctuation-only runs are still skipped.
|
- Fixed subtitle text that Yomitan's parser cannot match (truncated inflections like the volitional in とこ戻ろ…, elongation runs like ぅ~/ぉ〜) being rendered as plain, non-interactive text with no hover lookup. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation, while being fully ignored by frequency/JLPT highlighting, the N+1 candidate math, and vocabulary stats. Bracketed SFX/speaker captions and punctuation-only runs are still skipped.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface YomitanTokenInput {
|
|||||||
frequencyRank?: number;
|
frequencyRank?: number;
|
||||||
isNameMatch?: boolean;
|
isNameMatch?: boolean;
|
||||||
wordClasses?: string[];
|
wordClasses?: string[];
|
||||||
|
isUnparsedRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDepsFromYomitanTokens(
|
function makeDepsFromYomitanTokens(
|
||||||
@@ -60,6 +61,7 @@ function makeDepsFromYomitanTokens(
|
|||||||
isNameMatch: token.isNameMatch ?? false,
|
isNameMatch: token.isNameMatch ?? false,
|
||||||
frequencyRank: token.frequencyRank,
|
frequencyRank: token.frequencyRank,
|
||||||
wordClasses: token.wordClasses,
|
wordClasses: token.wordClasses,
|
||||||
|
isUnparsedRun: token.isUnparsedRun,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -4223,6 +4225,38 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle ignores unparsed-run tokens for annotations and N+1', async () => {
|
||||||
|
// もう いるぅ~!: the ぅ~ elongation has no Yomitan dictionary entry; it must
|
||||||
|
// not become the sole N+1 candidate or receive frequency/JLPT annotations.
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'もう いるぅ~!',
|
||||||
|
makeDepsFromYomitanTokens(
|
||||||
|
[
|
||||||
|
{ surface: 'もう', reading: 'もう', headword: 'もう' },
|
||||||
|
{ surface: 'いる', reading: 'いる', headword: 'いる' },
|
||||||
|
{ surface: 'ぅ~', reading: '', headword: 'ぅ~', isUnparsedRun: true, frequencyRank: 999 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getJlptLevel: (text) => (text === 'ぅ~' ? 'N5' : null),
|
||||||
|
isKnownWord: (text) => text === 'もう' || text === 'いる',
|
||||||
|
getMinSentenceWordsForNPlusOne: () => 2,
|
||||||
|
tokenizeWithMecab: async () => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filler = result.tokens?.find((token) => token.surface === 'ぅ~');
|
||||||
|
assert.ok(filler);
|
||||||
|
assert.equal(filler?.isNPlusOneTarget, false);
|
||||||
|
assert.equal(filler?.frequencyRank, undefined);
|
||||||
|
assert.equal(filler?.jlptLevel, undefined);
|
||||||
|
assert.equal(
|
||||||
|
result.tokens?.some((token) => token.isNPlusOneTarget),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
|
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'張り切ってんじゃ',
|
'張り切ってんじゃ',
|
||||||
|
|||||||
@@ -734,6 +734,7 @@ async function parseWithYomitanInternalParser(
|
|||||||
isNPlusOneTarget: false,
|
isNPlusOneTarget: false,
|
||||||
isNameMatch: token.isNameMatch ?? false,
|
isNameMatch: token.isNameMatch ?? false,
|
||||||
frequencyRank: token.frequencyRank,
|
frequencyRank: token.frequencyRank,
|
||||||
|
isUnparsedRun: token.isUnparsedRun === true ? true : undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -208,6 +208,48 @@ test('annotateTokens hides known-word marks while still using known words for N+
|
|||||||
assert.equal(result[2]?.isNPlusOneTarget, true);
|
assert.equal(result[2]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shouldExcludeTokenFromSubtitleAnnotations excludes unparsed-run tokens', () => {
|
||||||
|
// 戻 from 「…とこ戻ろ…」: Yomitan had no dictionary entry, headword falls back
|
||||||
|
// to the surface. Without the flag the token passes every other filter.
|
||||||
|
const unflagged = makeToken({ surface: '戻', headword: '戻', reading: '' });
|
||||||
|
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(unflagged), false);
|
||||||
|
|
||||||
|
const flagged = makeToken({ surface: '戻', headword: '戻', reading: '', isUnparsedRun: true });
|
||||||
|
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(flagged), true);
|
||||||
|
assert.equal(shouldExcludeTokenFromVocabularyPersistence(flagged), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotateTokens ignores unparsed-run tokens for annotations and N+1', () => {
|
||||||
|
// もう いるぅ~!-style line: the elongation run is the only unknown token and
|
||||||
|
// used to become the sole N+1 candidate despite having no dictionary entry.
|
||||||
|
const tokens = [
|
||||||
|
makeToken({ surface: 'みんな', headword: '皆', reading: 'みんな', startPos: 0, endPos: 3 }),
|
||||||
|
makeToken({ surface: 'とこ', headword: '所', reading: 'とこ', startPos: 3, endPos: 5 }),
|
||||||
|
makeToken({
|
||||||
|
surface: '戻',
|
||||||
|
headword: '戻',
|
||||||
|
reading: '',
|
||||||
|
startPos: 5,
|
||||||
|
endPos: 6,
|
||||||
|
isUnparsedRun: true,
|
||||||
|
frequencyRank: 12,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(
|
||||||
|
tokens,
|
||||||
|
makeDeps({
|
||||||
|
isKnownWord: (text) => text === '皆' || text === '所',
|
||||||
|
getJlptLevel: (text) => (text === '戻' ? 'N5' : null),
|
||||||
|
}),
|
||||||
|
{ minSentenceWordsForNPlusOne: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||||
|
assert.equal(result[2]?.jlptLevel, undefined);
|
||||||
|
assert.equal(result[2]?.frequencyRank, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
|
|||||||
@@ -146,13 +146,17 @@ test('emits unparsed non-caption text as a token with surface headword', () => {
|
|||||||
|
|
||||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
|
tokens?.map((token) => ({
|
||||||
|
surface: token.surface,
|
||||||
|
headword: token.headword,
|
||||||
|
isUnparsedRun: token.isUnparsedRun ?? false,
|
||||||
|
})),
|
||||||
[
|
[
|
||||||
{ surface: 'みんな', headword: '皆' },
|
{ surface: 'みんな', headword: '皆', isUnparsedRun: false },
|
||||||
{ surface: 'の', headword: 'の' },
|
{ surface: 'の', headword: 'の', isUnparsedRun: false },
|
||||||
{ surface: 'とこ', headword: '所' },
|
{ surface: 'とこ', headword: '所', isUnparsedRun: false },
|
||||||
{ surface: '戻', headword: '戻' },
|
{ surface: '戻', headword: '戻', isUnparsedRun: true },
|
||||||
{ surface: 'ろ', headword: '櫓' },
|
{ surface: 'ろ', headword: '櫓', isUnparsedRun: false },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
headword: string,
|
headword: string,
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
|
isUnparsedRun = false,
|
||||||
): void => {
|
): void => {
|
||||||
tokens.push({
|
tokens.push({
|
||||||
surface,
|
surface,
|
||||||
@@ -254,6 +255,7 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
const matchText = resolveKnownWordText(surface, headword, knownWordMatchMode);
|
const matchText = resolveKnownWordText(surface, headword, knownWordMatchMode);
|
||||||
return matchText ? isKnownWord(matchText) : false;
|
return matchText ? isKnownWord(matchText) : false;
|
||||||
})(),
|
})(),
|
||||||
|
...(isUnparsedRun ? { isUnparsedRun: true } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,10 +277,11 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
previousToken.reading += combinedReading;
|
previousToken.reading += combinedReading;
|
||||||
previousToken.endPos = end;
|
previousToken.endPos = end;
|
||||||
} else if (shouldEmitUnparsedRunAsToken(combinedSurface)) {
|
} else if (shouldEmitUnparsedRunAsToken(combinedSurface)) {
|
||||||
// Yomitan couldn't parse this run (e.g. 戻ろ… truncated volitional). Keep it
|
// Yomitan couldn't parse this run (e.g. 戻ろ… truncated volitional, ぅ~
|
||||||
// as a token with its surface as headword so it stays hoverable and counts in
|
// elongations). Keep it as a token with its surface as headword so it stays
|
||||||
// the n+1 math — matching what the embedded Yomitan actually returns.
|
// hoverable, but flag it so annotation/N+1/vocab logic ignores it — there is
|
||||||
pushToken(combinedSurface, combinedReading, combinedSurface, combinedStart, end);
|
// no dictionary entry behind it.
|
||||||
|
pushToken(combinedSurface, combinedReading, combinedSurface, combinedStart, end, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hasDictionaryMatch = true;
|
hasDictionaryMatch = true;
|
||||||
|
|||||||
@@ -474,6 +474,13 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
|
|||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
options: SubtitleAnnotationFilterOptions = {},
|
options: SubtitleAnnotationFilterOptions = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// No Yomitan dictionary entry backs this token (ぅ~ elongations, truncated
|
||||||
|
// inflections) — it exists only to stay hoverable and must never receive
|
||||||
|
// annotations or count in the N+1 math.
|
||||||
|
if (token.isUnparsedRun === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||||
const normalizedPos1 = normalizePosTag(token.pos1);
|
const normalizedPos1 = normalizePosTag(token.pos1);
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ test('requestYomitanScanTokens keeps scanner metadata for matching spans when pa
|
|||||||
headword: 'っ ',
|
headword: 'っ ',
|
||||||
startPos: 2,
|
startPos: 2,
|
||||||
endPos: 4,
|
endPos: 4,
|
||||||
|
isUnparsedRun: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
surface: 'ミナト',
|
surface: 'ミナト',
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface YomitanScanToken {
|
|||||||
isNameMatch?: boolean;
|
isNameMatch?: boolean;
|
||||||
frequencyRank?: number;
|
frequencyRank?: number;
|
||||||
wordClasses?: string[];
|
wordClasses?: string[];
|
||||||
|
isUnparsedRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface YomitanProfileMetadata {
|
interface YomitanProfileMetadata {
|
||||||
@@ -110,6 +111,27 @@ function scanTokenSpanKey(token: YomitanScanToken): string {
|
|||||||
return `${token.startPos}:${token.endPos}:${token.surface}`;
|
return `${token.startPos}:${token.endPos}:${token.surface}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps a parse-selected token to the scanner-token shape carried out of the
|
||||||
|
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
|
||||||
|
// projected fields stay in sync as the shape changes.
|
||||||
|
function toYomitanScanToken(token: {
|
||||||
|
surface: string;
|
||||||
|
reading: string;
|
||||||
|
headword: string;
|
||||||
|
startPos: number;
|
||||||
|
endPos: number;
|
||||||
|
isUnparsedRun?: boolean;
|
||||||
|
}): YomitanScanToken {
|
||||||
|
return {
|
||||||
|
surface: token.surface,
|
||||||
|
reading: token.reading,
|
||||||
|
headword: token.headword,
|
||||||
|
startPos: token.startPos,
|
||||||
|
endPos: token.endPos,
|
||||||
|
...(token.isUnparsedRun === true ? { isUnparsedRun: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// parseText segmentation is authoritative (it emits filler chunks for text the
|
// parseText segmentation is authoritative (it emits filler chunks for text the
|
||||||
// termsFind scanner skips), but only the termsFind scanner carries annotation
|
// termsFind scanner skips), but only the termsFind scanner carries annotation
|
||||||
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
|
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
|
||||||
@@ -1611,14 +1633,7 @@ export async function requestYomitanScanTokens(
|
|||||||
|
|
||||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||||
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
const parseScanTokens =
|
const parseScanTokens = selectedParseTokens?.map(toYomitanScanToken) ?? null;
|
||||||
selectedParseTokens?.map((token) => ({
|
|
||||||
surface: token.surface,
|
|
||||||
reading: token.reading,
|
|
||||||
headword: token.headword,
|
|
||||||
startPos: token.startPos,
|
|
||||||
endPos: token.endPos,
|
|
||||||
})) ?? null;
|
|
||||||
|
|
||||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||||
const profileIndex = metadata?.profileIndex ?? 0;
|
const profileIndex = metadata?.profileIndex ?? 0;
|
||||||
@@ -1656,15 +1671,7 @@ export async function requestYomitanScanTokens(
|
|||||||
}
|
}
|
||||||
if (Array.isArray(rawResult)) {
|
if (Array.isArray(rawResult)) {
|
||||||
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
|
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
|
||||||
return (
|
return selectedTokens?.map(toYomitanScanToken) ?? null;
|
||||||
selectedTokens?.map((token) => ({
|
|
||||||
surface: token.surface,
|
|
||||||
reading: token.reading,
|
|
||||||
headword: token.headword,
|
|
||||||
startPos: token.startPos,
|
|
||||||
endPos: token.endPos,
|
|
||||||
})) ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||||
return parseScanTokens;
|
return parseScanTokens;
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface MergedToken {
|
|||||||
isMerged: boolean;
|
isMerged: boolean;
|
||||||
isKnown: boolean;
|
isKnown: boolean;
|
||||||
isNPlusOneTarget: boolean;
|
isNPlusOneTarget: boolean;
|
||||||
|
/**
|
||||||
|
* Text Yomitan had no dictionary entry for (e.g. ぅ~ elongation runs,
|
||||||
|
* truncated inflections). Kept as a token so it stays hoverable, but
|
||||||
|
* ignored by annotation, N+1, and vocabulary-stats logic.
|
||||||
|
*/
|
||||||
|
isUnparsedRun?: boolean;
|
||||||
isNameMatch?: boolean;
|
isNameMatch?: boolean;
|
||||||
characterImage?: CharacterNameImage;
|
characterImage?: CharacterNameImage;
|
||||||
jlptLevel?: JlptLevel;
|
jlptLevel?: JlptLevel;
|
||||||
|
|||||||
Reference in New Issue
Block a user