diff --git a/changes/verify-known-word-highlights-script.md b/changes/verify-known-word-highlights-script.md new file mode 100644 index 00000000..cccc85a6 --- /dev/null +++ b/changes/verify-known-word-highlights-script.md @@ -0,0 +1,7 @@ +type: internal +area: tokenizer + +- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback. +- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs. +- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`. +- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes. diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index 3b00f1b5..c962dc5d 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -71,6 +71,16 @@ How often the `learning` color appears depends on your deck preset: with no rele While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature). +**Checking the colors you actually see:** + +Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file: + +```sh +bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit +``` + +It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit `, `--quiet`, `--json`. + ## Character-Name Highlighting Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail. diff --git a/package.json b/package.json index a0677c23..74ecac15 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts", "test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js", + "verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js", "record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js", "compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js", "build:yomitan": "bun scripts/build-yomitan.mjs", diff --git a/scripts/verify-known-word-highlights.ts b/scripts/verify-known-word-highlights.ts new file mode 100644 index 00000000..f21ae192 --- /dev/null +++ b/scripts/verify-known-word-highlights.ts @@ -0,0 +1,587 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import { + KnownWordCacheManager, + getKnownWordCacheLifecycleConfig, +} from '../src/anki-integration/known-word-cache.js'; +import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js'; +import { resolveConfigDir } from '../src/config/path-resolution.js'; +import { ConfigService } from '../src/config/service.js'; +import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js'; +import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js'; +import { + resolveCompleteTokenReading, + resolveKnownWordReadingForMatch, + resolveKnownWordText, +} from '../src/core/services/tokenizer/annotation-stage.js'; +import { MecabTokenizer } from '../src/mecab-tokenizer.js'; +import type { MergedToken } from '../src/types.js'; +import type { KnownWordMaturityTier } from '../src/types/subtitle.js'; +import { + createYomitanRuntimeStateWithSearch, + destroyParserWindow, + loadElectronModule, + withTimeout, + type YomitanRuntimeState, +} from './yomitan-script-runtime.js'; + +interface CliOptions { + input: string; + configDir?: string; + yomitanUserDataPath?: string; + yomitanExtensionPath?: string; + limit: number; + audit: boolean; + refresh: boolean; + json: boolean; + quiet: boolean; + profileCopy: boolean; +} + +type TierOrFallback = KnownWordMaturityTier | 'known-no-tier'; + +interface TokenReport { + cueIndex: number; + startTime: number; + surface: string; + headword: string; + reading: string; + tier: TierOrFallback; + noteIds: number[]; +} + +interface AuditMismatch extends TokenReport { + liveTier: KnownWordMaturityTier | 'no-notes'; + intervals: number[]; +} + +const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature']; +const FALLBACK_TIER_COLORS: Record = { + new: '#ee99a0', + learning: '#b7bdf8', + young: '#91d7e3', + mature: '#a6da95', +}; + +function parseCliArgs(argv: string[]): CliOptions { + const options: CliOptions = { + input: '', + limit: 0, + audit: false, + refresh: false, + json: false, + quiet: false, + profileCopy: false, + }; + const rest: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]!; + const takeValue = (flag: string): string => { + const next = argv[i + 1]; + if (!next) { + throw new Error(`Missing value for ${flag}`); + } + i += 1; + return next; + }; + + if (arg === '--help' || arg === '-h') { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } else if (arg === '--input') { + options.input = takeValue(arg); + } else if (arg === '--config-dir') { + options.configDir = takeValue(arg); + } else if (arg === '--yomitan-user-data') { + options.yomitanUserDataPath = takeValue(arg); + } else if (arg === '--yomitan-extension-path') { + options.yomitanExtensionPath = takeValue(arg); + } else if (arg === '--limit') { + options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0); + } else if (arg === '--audit') { + options.audit = true; + } else if (arg === '--refresh') { + options.refresh = true; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--quiet') { + options.quiet = true; + } else if (arg === '--profile-copy') { + options.profileCopy = true; + } else if (arg === '--') { + // `bun run