From 92d45b8a676689dbda6d01eb8a4575a6b7158ab4 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 06:45:44 +1300 Subject: [PATCH 1/2] Bundle the pinned Japanese dictionary with application distributions --- .github/workflows/electron-windows.yml | 2 +- .github/workflows/python-local-models.yml | 1 + .github/workflows/python-windows.yml | 2 +- .github/workflows/source-release.yml | 83 +++++++-- .gitignore | 3 + config/asset_packages.py | 13 +- .../voice/tts_japanese_dictionary.json | 8 +- config/japanese_dictionary.py | 17 ++ docs/external_asset_bundles.md | 20 +-- docs/install_profiles.md | 6 +- docs/japanese_pronunciation_dictionary.md | 68 +++++++- electron/electron-builder-code-only.cjs | 8 + electron/package.json | 10 +- electron/scripts/build-runtime-resources.cjs | 45 +++++ electron/src/main/bundledDictionary.ts | 7 + electron/src/main/index.ts | 2 + .../src/shared/configCatalog.generated.ts | 8 +- electron/tests/bundledDictionary.test.mjs | 55 ++++++ release/README.md | 90 +++++----- release/japanese_dictionary.json | 56 ++++++ release/source_release_policy.json | 7 +- tests/test_bundled_japanese_dictionary.py | 162 ++++++++++++++++++ tools/build_source_release.py | 122 ++++++++----- tools/probes/verify_bundled_dictionary.py | 76 ++++++++ tools/stage_japanese_dictionary.py | 106 ++++++++++++ tts/japanese_dictionary.py | 15 +- 26 files changed, 862 insertions(+), 130 deletions(-) create mode 100644 electron/electron-builder-code-only.cjs create mode 100644 electron/scripts/build-runtime-resources.cjs create mode 100644 electron/src/main/bundledDictionary.ts create mode 100644 electron/tests/bundledDictionary.test.mjs create mode 100644 release/japanese_dictionary.json create mode 100644 tests/test_bundled_japanese_dictionary.py create mode 100644 tools/probes/verify_bundled_dictionary.py create mode 100644 tools/stage_japanese_dictionary.py diff --git a/.github/workflows/electron-windows.yml b/.github/workflows/electron-windows.yml index d261417e..4fe6fea8 100644 --- a/.github/workflows/electron-windows.yml +++ b/.github/workflows/electron-windows.yml @@ -85,7 +85,7 @@ jobs: - name: Build the unpacked Windows application including beforePack env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: npx electron-builder --win --x64 --dir --publish never + run: npx electron-builder --config electron-builder-code-only.cjs --win --x64 --dir --publish never - name: Smoke packaged wallpaper startup resources and native tray shell: pwsh diff --git a/.github/workflows/python-local-models.yml b/.github/workflows/python-local-models.yml index 655cf7fe..dedf7b1d 100644 --- a/.github/workflows/python-local-models.yml +++ b/.github/workflows/python-local-models.yml @@ -77,6 +77,7 @@ jobs: tests/test_japanese_base_portability.py tests/test_japanese_aliases.py tests/test_japanese_full_resource.py + tests/test_bundled_japanese_dictionary.py - name: Switch the same environment to T3 vad run: | uv sync --locked --extra voice --extra vad --extra torch-cpu diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index b4b7eb8b..b5b3f947 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -116,7 +116,7 @@ jobs: working-directory: electron env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: npx electron-builder --win --x64 --dir --publish never + run: npx electron-builder --config electron-builder-code-only.cjs --win --x64 --dir --publish never - name: Launch packaged frontend with supplied Python runtime run: >- diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index 05f324d0..977627bd 100644 --- a/.github/workflows/source-release.yml +++ b/.github/workflows/source-release.yml @@ -13,10 +13,25 @@ on: - electron/package-lock.json - scripts/** - tools/build_source_release.py + - tools/stage_japanese_dictionary.py + - tools/probes/verify_bundled_dictionary.py + - electron/scripts/build-runtime-resources.cjs + - electron/electron-builder-code-only.cjs + - config/japanese_dictionary.py + - tests/test_bundled_japanese_dictionary.py - tools/check_third_party_provenance.py - tests/test_release_tooling.py - .github/workflows/source-release.yml workflow_dispatch: + inputs: + dictionary_url: + description: "HTTPS URL of the exact pinned resource (required for full application archives)" + type: string + required: false + code_only: + description: "Build an explicitly labelled developer archive without runtime resources" + type: boolean + default: false push: tags: - "v*" @@ -41,17 +56,43 @@ jobs: - name: Verify included third-party provenance run: python tools/check_third_party_provenance.py --release-ready + - name: Select distribution inputs + shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + CODE_ONLY: ${{ inputs.code_only }} + DICTIONARY_URL: ${{ inputs.dictionary_url }} + run: | + $codeOnly = $env:EVENT_NAME -eq 'pull_request' -or $env:CODE_ONLY -eq 'true' + if ($codeOnly) { + "SOURCE_ARCHIVE=amadeus-code-only.zip" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CODE_ONLY_DISTRIBUTION=true" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } else { + $pin = Get-Content release/japanese_dictionary.json -Raw | ConvertFrom-Json + $url = $env:DICTIONARY_URL + if (!$url) { $url = $pin.download_url } + if (!$url -or ([uri]$url).Scheme -ne 'https') { + throw 'Full application archives require the pinned dictionary URL; resources must be accepted and supplied before release.' + } + New-Item -ItemType Directory -Force build/release-inputs | Out-Null + Invoke-WebRequest -Uri $url -OutFile build/release-inputs/japanese-dictionary.zip + "SOURCE_ARCHIVE=amadeus-source.zip" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CODE_ONLY_DISTRIBUTION=false" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } + - name: Build deterministic source archive - run: >- - python tools/build_source_release.py - --manifest-output build/release/source-manifest.json - --output dist/amadeus-source.zip + shell: pwsh + run: | + $resourceArgs = @('--dictionary-bundle', 'build/release-inputs/japanese-dictionary.zip') + if ($env:CODE_ONLY_DISTRIBUTION -eq 'true') { $resourceArgs = @('--code-only') } + python tools/build_source_release.py --manifest-output build/release/source-manifest.json --output "dist/$env:SOURCE_ARCHIVE" @resourceArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Write SHA-256 checksum shell: pwsh run: | - $hash = (Get-FileHash -Algorithm SHA256 dist/amadeus-source.zip).Hash.ToLowerInvariant() - "$hash amadeus-source.zip" | Set-Content -Encoding ascii dist/SHA256SUMS.txt + $hash = (Get-FileHash -Algorithm SHA256 "dist/$env:SOURCE_ARCHIVE").Hash.ToLowerInvariant() + "$hash $env:SOURCE_ARCHIVE" | Set-Content -Encoding ascii dist/SHA256SUMS.txt # Repository builds cannot detect files missing from the allowlisted ZIP. # Install and launch the packaged frontend using only extracted sources. @@ -69,7 +110,7 @@ jobs: - name: Extract the release archive shell: pwsh run: | - Expand-Archive -LiteralPath dist/amadeus-source.zip -DestinationPath build/source-smoke + Expand-Archive -LiteralPath "dist/$env:SOURCE_ARCHIVE" -DestinationPath build/source-smoke $manifest = Get-Content build/release/source-manifest.json -Raw | ConvertFrom-Json $sourceRoot = Join-Path $PWD "build/source-smoke/amadeus-$($manifest.version)" "SOURCE_ROOT=$sourceRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append @@ -82,6 +123,14 @@ jobs: working-directory: ${{ env.SOURCE_ROOT }} run: uv run --locked --no-sync python tools/verify_python_environment.py --profile ci + - name: Verify the extracted bundled dictionary with the native frontend + if: env.CODE_ONLY_DISTRIBUTION == 'false' + working-directory: ${{ env.SOURCE_ROOT }} + run: >- + uv run --locked --no-sync --with pyopenjtalk-plus==0.4.1.post9 + python -X utf8 tools/probes/verify_bundled_dictionary.py + --output build/source-release-smoke/dictionary-source.json + - name: Install Electron and Pi from extracted sources working-directory: ${{ env.SOURCE_ROOT }}/electron run: npm ci @@ -101,7 +150,21 @@ jobs: working-directory: ${{ env.SOURCE_ROOT }}/electron env: CSC_IDENTITY_AUTO_DISCOVERY: "false" - run: npx electron-builder --win --x64 --dir --publish never + shell: pwsh + run: | + $configArgs = @() + if ($env:CODE_ONLY_DISTRIBUTION -eq 'true') { $configArgs = @('--config', 'electron-builder-code-only.cjs') } + npx electron-builder --win --x64 --dir --publish never @configArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify the dictionary carried by the Electron package + if: env.CODE_ONLY_DISTRIBUTION == 'false' + working-directory: ${{ env.SOURCE_ROOT }} + shell: pwsh + run: | + $env:AMADEUS_BUNDLED_JAPANESE_DICTIONARY = Join-Path $PWD 'electron/build/win-unpacked/resources/assets/bundled/japanese-pronunciation' + uv run --locked --no-sync --with pyopenjtalk-plus==0.4.1.post9 python -X utf8 tools/probes/verify_bundled_dictionary.py --output build/source-release-smoke/dictionary-electron.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Launch packaged application from the source archive working-directory: ${{ env.SOURCE_ROOT }} @@ -120,9 +183,9 @@ jobs: - uses: actions/upload-artifact@v6 with: - name: amadeus-source-${{ github.sha }} + name: ${{ env.SOURCE_ARCHIVE }}-${{ github.sha }} path: | - dist/amadeus-source.zip + dist/*.zip dist/SHA256SUMS.txt build/release/source-manifest.json if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6c8c457c..860fa704 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ assets/audio/sfx/*.wav # 口型调试用参考图(本地生成) tools/debug_mouth/*.png + +# Generated, independently versioned defaults are injected only into distributions. +/assets/bundled/ diff --git a/config/asset_packages.py b/config/asset_packages.py index e69e7e79..abb98062 100644 --- a/config/asset_packages.py +++ b/config/asset_packages.py @@ -134,7 +134,13 @@ def external_asset_pack_status( except KeyError as exc: raise AssetPackageError(f"unknown external asset pack: {pack_id}") from exc - present = [path for path in spec.required if asset_path(asset_root, path).is_file()] + dictionary_root = None + if spec.validator == "japanese_dictionary": + from config.japanese_dictionary import dictionary_pack_root + dictionary_root = dictionary_pack_root(asset_root) + present = [path for path in spec.required + if (dictionary_root / path.relative_to(spec.trees[0]) if dictionary_root is not None + else asset_path(asset_root, path)).is_file()] missing = [path for path in spec.required if path not in present] message = "" version = "" @@ -156,7 +162,7 @@ def external_asset_pack_status( if state == "installed" and spec.validator == "japanese_dictionary": from config.japanese_dictionary import JapaneseDictionaryError, load_dictionary_pack try: - pack = load_dictionary_pack(asset_path(asset_root, spec.trees[0])) + pack = load_dictionary_pack(dictionary_root) version = pack.version if pack.format_version == 2: message = (f"{pack.entry_count} source rows preserved; {pack.reviewed_rows} reviewed reading corrections; " @@ -197,6 +203,7 @@ def external_asset_pack_status( "required_count": len(spec.required), "missing": [path.as_posix() for path in missing], "message": message, - **({"version": version} if spec.validator == "japanese_dictionary" else {}), + **({"version": version, "source": "installed" if dictionary_root == asset_path(asset_root, spec.trees[0]) + else "bundled"} if spec.validator == "japanese_dictionary" else {}), "install_command": "python tools/external_assets.py install ", } diff --git a/config/catalog/voice/tts_japanese_dictionary.json b/config/catalog/voice/tts_japanese_dictionary.json index 8f3fb21a..1a669cd9 100644 --- a/config/catalog/voice/tts_japanese_dictionary.json +++ b/config/catalog/voice/tts_japanese_dictionary.json @@ -2,8 +2,8 @@ "id": "tts_japanese_dictionary", "title": {"en-US": "Japanese pronunciation dictionary", "zh-CN": "日语发音词典"}, "description": { - "en-US": "Use an optionally installed dictionary for English words and names in local Japanese speech.", - "zh-CN": "为本地日语语音使用可选安装的英文单词与人名读音词典。" + "en-US": "Use the bundled Japanese pronunciation dictionary or an explicitly installed independent version.", + "zh-CN": "使用应用随附的日语读音词典,或显式安装的独立版本。" }, "desktop": true, "apply": "backend_restart", @@ -12,8 +12,8 @@ "type": "boolean", "title": {"en-US": "Use installed Japanese dictionaries", "zh-CN": "使用已安装的日语词典"}, "description": { - "en-US": "Does not download a dictionary. Install japanese-pronunciation separately. Turn off to use the base dictionary; restart the backend after changes.", - "zh-CN": "不会下载词典,请单独安装 japanese-pronunciation。关闭后使用基础词典;更改后需重启后端。" + "en-US": "Application distributions include a default dictionary. Explicit dictionary updates or rollbacks take precedence; personal entries are preserved. Turn off to use the base dictionary and restart the backend.", + "zh-CN": "应用发行包已包含默认词典。显式安装的更新或回退版本优先生效,个人词条保留。关闭后使用基础词典;更改后需重启后端。" }, "default": true, "accepted_values": ["true", "false", "1", "0", "yes", "no"], diff --git a/config/japanese_dictionary.py b/config/japanese_dictionary.py index f4715334..c2c26443 100644 --- a/config/japanese_dictionary.py +++ b/config/japanese_dictionary.py @@ -6,6 +6,7 @@ import hashlib import io import json +import os import re from functools import lru_cache from pathlib import Path @@ -14,6 +15,8 @@ PACK_ID = "japanese-pronunciation" PACK_TREE = "text/ja/english" +BUNDLED_TREE = "bundled/japanese-pronunciation" +BUNDLED_ENV = "AMADEUS_BUNDLED_JAPANESE_DICTIONARY" LATIN_WORD_BOUNDARY = "\u3000" KANJI_COMPOUND = re.compile(r"[\u3400-\u9fff々]{2,}") KATAKANA_READING = re.compile(r"[\u30a1-\u30fa\u30fc]+") @@ -24,6 +27,20 @@ FULL_SOURCE_FILES = ("source.csv", "overlay.csv", "OpenJTalk-COPYING.txt") +def dictionary_pack_root(asset_root: Path) -> Path: + """Explicit independent installs own selection; app updates replace only the default. + + An incomplete explicit installation remains visible as an error instead of + silently reverting a user's update or rollback to the bundled version. + """ + installed = asset_root / PACK_TREE + if installed.exists(): + return installed + supplied = os.environ.get(BUNDLED_ENV, "") + bundled = Path(supplied) if supplied else asset_root / BUNDLED_TREE + return bundled if bundled.exists() else installed + + def latin_word_key(value: str) -> str: return unicodedata.normalize("NFKC", value).translate(str.maketrans("’−", "'-")).casefold().strip(" ") diff --git a/docs/external_asset_bundles.md b/docs/external_asset_bundles.md index cf4ad5ca..d824d9f7 100644 --- a/docs/external_asset_bundles.md +++ b/docs/external_asset_bundles.md @@ -137,16 +137,16 @@ reviewed before publishing either voice archive. ## Archive contract -The independent optional `japanese-pronunciation` pack contains a pinned, -corrected Japanese word/name CSV and its provenance/license notices. Build, -verify and install use the existing commands; compiled and personal dictionaries -are excluded. This requires an application version with the new catalog entry. -See [Japanese pronunciation dictionary](japanese_pronunciation_dictionary.md). -Publish it alongside the compatible application source ZIP in the same GitHub -Release, with checksums and the tested version pair. It remains optional and -does not require rebuilding other runtime archives. Later format changes ship -updated resources with the new application; old pack contracts need not be -retained indefinitely. Keep published filenames and bytes unchanged. +Application releases include the independently versioned `japanese-pronunciation` +resource as a bundled default; consumers do not install it separately. Maintainers +pin and validate its archive and all nine files before building either source or +Electron application distributions. Explicit independent installs under +`assets/text/ja/english` take precedence, preserving data-only updates and rollback +across application upgrades. Personal `assets/text/ja/user.csv` stays separate. +See [Japanese pronunciation dictionary](japanese_pronunciation_dictionary.md) +for staging, release gates, actual loading checks and returning to the default. +The same immutable independent ZIP may be supplied with the application Release +for data-only updates; other voice/model/media archives remain unchanged. The optional `voice-kurisu-emotions` pack adds paired Japanese semantic references to an existing Windows CUDA V3 installation. It uses the same bundle contract diff --git a/docs/install_profiles.md b/docs/install_profiles.md index 833f579c..7cfc54cd 100644 --- a/docs/install_profiles.md +++ b/docs/install_profiles.md @@ -81,8 +81,10 @@ qualification path and needs PortAudio for PyAudio; the macos-14 candidate job also installs `local-mps` and switches back through T3 vad. A successful install, import or Electron build does not replace microphone, playback and desktop acceptance. The reference cu124 profile is Windows-only. The cu128 candidate targets -Windows/Linux x86_64; the MPS candidate targets Apple Silicon macOS. Local model weights, reference audio -and dictionaries are external assets and are not downloaded by this installer. +Windows/Linux x86_64; the MPS candidate targets Apple Silicon macOS. Local model +weights and reference audio remain external and are not downloaded by this installer. +Full application distributions already contain the pinned Japanese dictionary; +independent dictionary updates/rollbacks and personal entries remain separate. Local ASR/TTS model loading enforces Hugging Face offline mode even when the parent process sets `HF_HUB_OFFLINE=0` or Transformers/Hub have already been diff --git a/docs/japanese_pronunciation_dictionary.md b/docs/japanese_pronunciation_dictionary.md index d99f240a..1f63396c 100644 --- a/docs/japanese_pronunciation_dictionary.md +++ b/docs/japanese_pronunciation_dictionary.md @@ -1,7 +1,8 @@ # Independently versioned Japanese pronunciation resources -The optional dictionary is versioned independently of the application. Mainline -owns loading, validation, matching, cache identity and build/test tools. Full +Application distributions include a pinned default dictionary. Its data version +remains independent of the application version. Mainline owns loading, validation, +matching, cache identity and build/test tools. Full source, corrections and research history belong in a separate data Git repository. Generated CSVs, ZIPs, compiled dictionaries and recordings are not application code. @@ -100,9 +101,23 @@ check results for platform qualification. ## Install, update and rollback -Use an application with the format-2 loader. Published format-1 packs remain -supported for rollback. One official pack takes precedence over legacy data; -personal entries remain separate. +A full application archive installs the default under +`assets/bundled/japanese-pronunciation`; Electron packages carry the same directory +in their resources and pass its location to the Python backend. No separate +consumer installation step is required. The existing dictionary switch defaults +to enabled; a saved disabled setting remains respected. + +Selection is explicit: an independently installed `assets/text/ja/english` pack +wins, otherwise use the bundled default, otherwise the legacy CSV/base path. +Application upgrades replace only the bundled default. Existing independent +installs (including older format-1 packs) retain the user's chosen version, so +updates/rollbacks are not undone at the next startup or application update. An +invalid explicit install is reported without silently selecting the default. +Personal `assets/text/ja/user.csv` is never part of a distribution or replaced by +application/resource upgrades. To resume following the bundled default, move the +independent `assets/text/ja/english` directory aside; keep `user.csv` in place. + +Published format-1 packs remain supported for independent rollback: ```powershell python tools/external_assets.py verify @@ -187,3 +202,46 @@ linguistic checks, not newly approved mappings; ambiguous names must not receive one forced reading. The unverified-but-enabled admission policy (#244) still requires explicit maintainer acceptance. Keep the PR draft and resource unpublished until that decision and listening acceptance are complete. + +### Default delivery and release gate + +`release/japanese_dictionary.json` fixes the data version, archive SHA-256, +resource identity and all nine member hashes for the application release. Updating +this pin changes the default data version without embedding CSV data in Git. +The independent ZIP and data history retain their versions and immutable bytes. + +Build a full application source archive with: + +```powershell +python tools/build_source_release.py --dictionary-bundle --output --manifest-output +``` + +The builder validates the independent archive and injects only its nine resource +files into the source archive. Missing, wrong-version or corrupt data blocks the +build; personal CSVs and compiled caches cannot be included. For Electron builds +from a Git checkout, first stage that same ZIP: + +```powershell +python tools/stage_japanese_dictionary.py --archive +cd electron +npm run electron:build -- --publish never +``` + +Building Electron from a full extracted source archive uses its already included +default. The packaging hook verifies every member against the same pin. Independent +updates continue to use `tools/external_assets.py install ... --overwrite`. + +Developer/PR archives may explicitly use `--code-only` and a filename containing +`code-only`. Their Electron equivalent uses `electron-builder-code-only.cjs`. +These artifacts are not application distributions. Tag/full dispatch builds must +supply an HTTPS URL of the exact pinned ZIP (workflow input or the pin's approved +`download_url`); no missing-data fallback is allowed. The URL is intentionally +unset while the resource remains unpublished. Local full builds use the existing +validated ZIP and require no remote publication. + +Run `tools/probes/verify_bundled_dictionary.py` from extracted sources with native +voice dependencies installed. It verifies the actually selected version/source, +resource identity and menu/game/Disney/中立派 through the production text frontend. +This is loading/regression evidence, not a new acoustic or full-vocabulary accuracy +claim. CI also exercises synthetic fresh-install, default-upgrade, independent +update/rollback, personal preservation and packaging rejection contracts. diff --git a/electron/electron-builder-code-only.cjs b/electron/electron-builder-code-only.cjs new file mode 100644 index 00000000..11452504 --- /dev/null +++ b/electron/electron-builder-code-only.cjs @@ -0,0 +1,8 @@ +// Explicit developer/CI shell; never label this as a full application distribution. +const config = require('./package.json').build +module.exports = { + ...config, + artifactName: 'amadeus-code-only-${version}-${arch}.${ext}', + beforePack: 'scripts/build-windows-wallpaper.cjs', + extraResources: config.extraResources.filter(item => item.to !== 'assets/bundled/japanese-pronunciation'), +} diff --git a/electron/package.json b/electron/package.json index a9d3a848..c9641db2 100644 --- a/electron/package.json +++ b/electron/package.json @@ -14,13 +14,17 @@ "check:pi": "npm --prefix ../agent_host/pi_runtime run typecheck", "build": "npm run check:config && npm run check:extension-sdk && npm run check:pi && tsc && vite build", "electron:dev": "npm run check:extension-sdk && node --experimental-strip-types scripts/dev.mjs --electron", - "electron:build": "npm run build && electron-builder" + "electron:build": "npm run build && electron-builder", + "electron:build:code-only": "npm run build && electron-builder --config electron-builder-code-only.cjs" }, "build": { "directories": { "output": "build" }, "files": ["dist/main/**/*", "dist/shared/**/*", "dist/preload/**/*", "dist/renderer/**/*", "package.json"], - "extraResources": [{ "from": "../vendor/extension-sdk", "to": "extension-sdk", "filter": ["*.whl"] }], - "beforePack": "scripts/build-windows-wallpaper.cjs", + "extraResources": [ + { "from": "../vendor/extension-sdk", "to": "extension-sdk", "filter": ["*.whl"] }, + { "from": "../build/bundled-assets/assets/bundled/japanese-pronunciation", "to": "assets/bundled/japanese-pronunciation", "filter": ["**/*"] } + ], + "beforePack": "scripts/build-runtime-resources.cjs", "win": { "icon": "../assets/icons/app/app_icon.ico", "extraResources": [ diff --git a/electron/scripts/build-runtime-resources.cjs b/electron/scripts/build-runtime-resources.cjs new file mode 100644 index 00000000..8277c08a --- /dev/null +++ b/electron/scripts/build-runtime-resources.cjs @@ -0,0 +1,45 @@ +const fs = require('node:fs') +const path = require('node:path') +const { createHash } = require('node:crypto') +const buildWallpaper = require('./build-windows-wallpaper.cjs') + +function verifyDictionary(directory, pin) { + const names = ['userdict.csv', 'provenance.json', 'NOTICE.md', 'GPT-SoVITS-MIT.txt', + 'CMUdict-BSD-2-Clause.txt', 'Amadeus-AGPL-3.0.txt', 'source.csv', 'overlay.csv', 'OpenJTalk-COPYING.txt'].sort() + const expected = pin.files.map(record => path.basename(record.path)).sort() + if (pin.format !== 'amadeus.bundled-japanese-dictionary.v1' || pin.pack_id !== 'japanese-pronunciation' + || JSON.stringify(expected) !== JSON.stringify(names) + || pin.files.some(record => record.path !== `assets/text/ja/english/${path.basename(record.path)}`)) { + throw new Error('Invalid bundled dictionary pin; personal/compiled files are not distributable') + } + if (!fs.existsSync(directory) || JSON.stringify(fs.readdirSync(directory).sort()) !== JSON.stringify(expected)) { + throw new Error('Pinned Japanese dictionary is missing. Run tools/stage_japanese_dictionary.py --archive before application packaging.') + } + for (const record of pin.files) { + const data = fs.readFileSync(path.join(directory, path.basename(record.path))) + if (data.length !== record.size || createHash('sha256').update(data).digest('hex') !== record.sha256) { + throw new Error(`Bundled Japanese dictionary differs from release pin: ${path.basename(record.path)}`) + } + } +} + +function prepareDictionary(root) { + const pin = JSON.parse(fs.readFileSync(path.join(root, 'release/japanese_dictionary.json'), 'utf8')) + const staged = path.join(root, 'build/bundled-assets/assets/bundled/japanese-pronunciation') + const inSource = path.join(root, 'assets/bundled/japanese-pronunciation') + const source = fs.existsSync(inSource) ? inSource : staged + verifyDictionary(source, pin) + if (source !== staged) { + fs.mkdirSync(staged, { recursive: true }) + for (const record of pin.files) fs.copyFileSync(path.join(source, path.basename(record.path)), path.join(staged, path.basename(record.path))) + verifyDictionary(staged, pin) + } + return staged +} + +module.exports = async context => { + prepareDictionary(path.resolve(__dirname, '../..')) + await buildWallpaper(context) +} +module.exports.prepareDictionary = prepareDictionary +module.exports.verifyDictionary = verifyDictionary diff --git a/electron/src/main/bundledDictionary.ts b/electron/src/main/bundledDictionary.ts new file mode 100644 index 00000000..10be84c5 --- /dev/null +++ b/electron/src/main/bundledDictionary.ts @@ -0,0 +1,7 @@ +import path from 'node:path' + +/** The executable chooses its immutable default; Python keeps explicit installs first. */ +export function bundledDictionaryEnvironment(projectRoot: string, resourcesPath: string, packaged: boolean): Record { + return { AMADEUS_BUNDLED_JAPANESE_DICTIONARY: path.join(packaged ? resourcesPath : projectRoot, + 'assets', 'bundled', 'japanese-pronunciation') } +} diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 221931e8..dc7e1c82 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'url' import { CompanionPanel } from './companionPanel.js' import { companionPortraitStatus } from './companionPortraits.js' import { projectPythonCommand } from './pythonRuntime.js' +import { bundledDictionaryEnvironment } from './bundledDictionary.js' import { DesktopSettingsStore, type DesktopSettingsUpdate, @@ -458,6 +459,7 @@ async function launchBackend(): Promise { cwd: PROJECT_ROOT, env: { ...backendProcessEnvironment, + ...bundledDictionaryEnvironment(PROJECT_ROOT, process.resourcesPath, app.isPackaged), ...defaultMpsFallbackEnvironment(process.platform, process.arch, backendProcessEnvironment), PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', diff --git a/electron/src/shared/configCatalog.generated.ts b/electron/src/shared/configCatalog.generated.ts index 6dd4b7b7..fff0f31d 100644 --- a/electron/src/shared/configCatalog.generated.ts +++ b/electron/src/shared/configCatalog.generated.ts @@ -3144,8 +3144,8 @@ export const catalogGroups: CatalogGroup[] = [ "zh-CN": "日语发音词典" }, "description": { - "en-US": "Use an optionally installed dictionary for English words and names in local Japanese speech.", - "zh-CN": "为本地日语语音使用可选安装的英文单词与人名读音词典。" + "en-US": "Use the bundled Japanese pronunciation dictionary or an explicitly installed independent version.", + "zh-CN": "使用应用随附的日语读音词典,或显式安装的独立版本。" }, "desktop": true, "apply": "backend_restart", @@ -3157,8 +3157,8 @@ export const catalogGroups: CatalogGroup[] = [ "zh-CN": "使用已安装的日语词典" }, "description": { - "en-US": "Does not download a dictionary. Install japanese-pronunciation separately. Turn off to use the base dictionary; restart the backend after changes.", - "zh-CN": "不会下载词典,请单独安装 japanese-pronunciation。关闭后使用基础词典;更改后需重启后端。" + "en-US": "Application distributions include a default dictionary. Explicit dictionary updates or rollbacks take precedence; personal entries are preserved. Turn off to use the base dictionary and restart the backend.", + "zh-CN": "应用发行包已包含默认词典。显式安装的更新或回退版本优先生效,个人词条保留。关闭后使用基础词典;更改后需重启后端。" }, "default": true, "accepted_values": [ diff --git a/electron/tests/bundledDictionary.test.mjs b/electron/tests/bundledDictionary.test.mjs new file mode 100644 index 00000000..79da50cc --- /dev/null +++ b/electron/tests/bundledDictionary.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { bundledDictionaryEnvironment } from '../src/main/bundledDictionary.ts' +const require = createRequire(import.meta.url) +const { prepareDictionary, verifyDictionary } = require('../scripts/build-runtime-resources.cjs') +const pin = JSON.parse(fs.readFileSync(new URL('../../release/japanese_dictionary.json', import.meta.url), 'utf8')) + +test('packaged and source launches supply the actual immutable dictionary location', () => { + assert.deepEqual(bundledDictionaryEnvironment('/backend', '/app/resources', true), + { AMADEUS_BUNDLED_JAPANESE_DICTIONARY: path.join('/app/resources/assets/bundled/japanese-pronunciation') }) + assert.deepEqual(bundledDictionaryEnvironment('/backend', '/app/resources', false), + { AMADEUS_BUNDLED_JAPANESE_DICTIONARY: path.join('/backend/assets/bundled/japanese-pronunciation') }) +}) + +test('application packaging rejects a missing or tampered default, and never includes personal files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-bundled-')) + try { + fs.mkdirSync(path.join(root, 'release')) + const fixture = structuredClone(pin) + const source = path.join(root, 'assets/bundled/japanese-pronunciation') + assert.throws(() => verifyDictionary(source, fixture), /missing/) + fs.mkdirSync(source, { recursive: true }) + for (const record of fixture.files) { + const data = Buffer.from(path.basename(record.path)) + record.size = data.length + record.sha256 = createHash('sha256').update(data).digest('hex') + fs.writeFileSync(path.join(source, path.basename(record.path)), data) + } + fs.writeFileSync(path.join(root, 'release/japanese_dictionary.json'), JSON.stringify(fixture)) + const staged = prepareDictionary(root) + assert.equal(fs.readdirSync(staged).length, 9) + verifyDictionary(staged, fixture) + fs.writeFileSync(path.join(source, 'user.csv'), 'personal') + assert.throws(() => prepareDictionary(root), /missing|unexpected/) + fs.unlinkSync(path.join(source, 'user.csv')) + fs.appendFileSync(path.join(source, 'userdict.csv'), 'changed') + assert.throws(() => prepareDictionary(root), /differs/) + fixture.files[0].path = 'assets/text/ja/user.csv' + assert.throws(() => verifyDictionary(staged, fixture), /personal/) + } finally { fs.rmSync(root, { recursive: true, force: true }) } +}) + +test('only the explicitly named developer config omits dictionary resources', () => { + const app = require('../package.json').build + const developer = require('../electron-builder-code-only.cjs') + assert.equal(app.beforePack, 'scripts/build-runtime-resources.cjs') + assert.ok(app.extraResources.some(r => r.to === 'assets/bundled/japanese-pronunciation')) + assert.ok(!developer.extraResources.some(r => r.to === 'assets/bundled/japanese-pronunciation')) + assert.match(developer.artifactName, /code-only/) +}) diff --git a/release/README.md b/release/README.md index 78f06766..2aee7f1f 100644 --- a/release/README.md +++ b/release/README.md @@ -4,9 +4,9 @@ These tools prepare a reviewable source archive. They do not change product semantics, install runtime dependencies, move assets, rewrite Git history, or silently waive a release blocker. -Separately distributed runtime media uses `tools/external_assets.py`; it is not -part of the public source-archive workflow. See -`docs/external_asset_bundles.md`. +Model and creative media remain separate and use `tools/external_assets.py`. +The pinned Japanese dictionary is included in application archives through the +same resource contract. See `docs/external_asset_bundles.md` and section 6 below. The public archive uses the open-source model recorded in `source_release_policy.json`. Its root `LICENSE` applies only to Amadeus @@ -107,6 +107,7 @@ Only a clean tree with no gate error can produce an archive: ```powershell .venv\Scripts\python.exe tools\build_source_release.py ` --manifest-output build\release\source-manifest.json ` + --dictionary-bundle ` --output dist\amadeus-source.zip ``` @@ -114,7 +115,7 @@ Archive entries are sorted, share the Git commit timestamp, preserve executable mode where Git records it, and include `SOURCE_MANIFEST.json`. Existing outputs are not overwritten unless `--overwrite` is explicit. -## 5. Current expected blockers +## 5. Historical provenance exclusions At the 2026-08-29 audit baseline, BigVGAN, AP-BWE, pixi-live2d-display, and Basis/KTX2 have a recorded disposition. Generated GPT-SoVITS caches, the @@ -135,42 +136,45 @@ These are useful failures. Do not turn them into broad exceptions merely to make the command green; either resolve the evidence, exclude the component, or record a narrow, reviewed policy exception. -## 5. Publish the application and optional resources together - -Publish checked optional dictionary resources as separate attachments of the -**application Release**, alongside the source ZIP, `source-manifest.json` and -`SHA256SUMS.txt`. The dictionary remains optional; do not embed it in the source -archive or combine it with voice/model media. The Release notes identify the -application version, dictionary data version, tested compatibility and limits. -The resource page links to that application Release. - -Before publishing each application version: - -1. Synchronize Python/Electron versions and lockfiles, record changes and upgrade - requirements, and pass the final preparation commit's required CI. -2. Tag the accepted mainline commit. Wait for the tag's **Source Release** workflow - to pass installation, build, previous-release upgrade, packaging and startup - using only extracted sources. Download its source archive and manifest. -3. Choose the checked dictionary for this application. Run the production - `validate_japanese_dictionary.py` gate with this application's code; verify and - clean-install the ZIP through this application's `external_assets.py`. If the - data is unchanged, reuse the identical ZIP only after it passes these checks. - For corrected/expanded data, prepare a new data version and immutable filename. -4. Create a draft application Release and attach the tested source ZIP, source - manifest, dictionary ZIP and dictionary checksum sidecar. `SHA256SUMS.txt` - covers every attached payload (including the dictionary and source manifest). - Record source/license notices and any experimental or unqualified behavior. -5. Verify uploaded names, sizes and digests before publishing. Download the - public files and recheck their SHA-256, source member manifest and dictionary - installation. Update the resource page after the application Release is live. - -Do not hardcode a dictionary data version into the application or require future -code to support an old resource indefinitely. The existing asset `spec_version` -is the format compatibility boundary. A breaking pack change advances that -contract and ships a matching resource with the new application. Keep older -Release assets for their corresponding application versions; never replace a -same-named published ZIP. No recurring publication or automatic upload is implied. - -Starting pair: application `v0.17.1-alpha.0`, optional dictionary `2026.10.20.3`. -The separate `japanese-context-reading` model is not part of this distribution; -its known quality failures remain disclosed and it is disabled by default. +## 6. Bundle the default dictionary with the application + +Every application distribution includes the exact dictionary selected by +`release/japanese_dictionary.json`. Keep data in its independent versioned source +repository and immutable resource ZIP; application Git contains the pin, loader, +validators and tooling, not generated CSV data. Personal dictionaries and compiled +caches are never distribution inputs. Other model/media packs remain separate. + +Before publishing an application version: + +1. Accept the application and dictionary combination, update the pin if data + changed, and synchronize application version/lockfiles. No automatic choice of + an untested newest resource is made at installation time. +2. Build `python tools/build_source_release.py --dictionary-bundle + --output --manifest-output `. + The default nine files are already installed when the archive is extracted. + Missing or mismatched resources block application archive creation. +3. Build Electron from the extracted full source archive, or stage the pinned ZIP + with `tools/stage_japanese_dictionary.py` before building from Git. The default + Electron packaging hook requires the same pinned members and includes them. +4. Test fresh extraction, upgrade of the bundled default, actual native dictionary + loading, independent update and rollback, and unchanged personal `user.csv`. + Explicit independent selections retain precedence across application updates. +5. The Source Release workflow builds full application artifacts for tags or a + full manual dispatch. Supply the accepted ZIP URL through `dictionary_url` or + set the pin's approved `download_url` before tagging. Downloaded bytes must + match the committed hash. The URL remains unset before publication approval; + local builds can still be fully validated using the local ZIP. +6. Only after acceptance and explicit publication approval, attach the complete + application artifacts, manifests and checksums to the application Release. + The identical independent dictionary ZIP may also be attached for users who + want data-only updates/rollback. Never replace same-named published payloads. + +PR/developer checks use explicitly named `amadeus-code-only.zip` and +`electron-builder-code-only.cjs`; these are not complete application releases. +They do not claim to validate the private/unpublished real dictionary in cloud CI. +The local acceptance report must bind the real pinned resource to the application +head before release. Current development retains the candidate resource and does +not authorize a new application version, tag, merge or publication. + +The context-reading model remains a separate, default-off experiment and is not +part of default dictionary delivery. diff --git a/release/japanese_dictionary.json b/release/japanese_dictionary.json new file mode 100644 index 00000000..71e8371c --- /dev/null +++ b/release/japanese_dictionary.json @@ -0,0 +1,56 @@ +{ + "format": "amadeus.bundled-japanese-dictionary.v1", + "pack_id": "japanese-pronunciation", + "version": "2026.10.22.1-rc2", + "archive": "amadeus-japanese-pronunciation-2026.10.22.1-rc2.zip", + "sha256": "a935b3adfded9022afe3b2384cebc2ef1e4023f54f48df1f9ded9ff45b896989", + "resource_identity": "298576bfaf6e553f55631b2bbbea8099deb9b3919903bce8f646ca2ecd791c46", + "download_url": null, + "files": [ + { + "path": "assets/text/ja/english/Amadeus-AGPL-3.0.txt", + "sha256": "8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef", + "size": 34523 + }, + { + "path": "assets/text/ja/english/CMUdict-BSD-2-Clause.txt", + "sha256": "6fec176463ab1aa60e17ab42d174f3720415ffc72f9d92b66790432082490e81", + "size": 1928 + }, + { + "path": "assets/text/ja/english/GPT-SoVITS-MIT.txt", + "sha256": "8a064eada186601fc602e45feef61c6abb62246d5ae9c3764dd1fc92d946c3c6", + "size": 1065 + }, + { + "path": "assets/text/ja/english/NOTICE.md", + "sha256": "957c57833853bee2d23aab5c777aae9d2a52bda6b752a1629913af3150b98730", + "size": 33560 + }, + { + "path": "assets/text/ja/english/OpenJTalk-COPYING.txt", + "sha256": "59099a8c7c2acb68ef9945e9e80f352b1583c62b2023ac47d8f33bd371f5b1b6", + "size": 5965 + }, + { + "path": "assets/text/ja/english/overlay.csv", + "sha256": "44cba01b1a0d0f3f114724827fa4084c66dbb27de7a66ff5ea249540c0a69e55", + "size": 527580 + }, + { + "path": "assets/text/ja/english/provenance.json", + "sha256": "298576bfaf6e553f55631b2bbbea8099deb9b3919903bce8f646ca2ecd791c46", + "size": 2287937 + }, + { + "path": "assets/text/ja/english/source.csv", + "sha256": "cf3ae9063ee6b171f5553900fcaa813238a8d20bb0917a6a3a0f8d0c35c27962", + "size": 17315171 + }, + { + "path": "assets/text/ja/english/userdict.csv", + "sha256": "195daed9bd3aa3d9ed14008c3f927143fe7c1dea7890803d83638cef43608fea", + "size": 17174885 + } + ] +} diff --git a/release/source_release_policy.json b/release/source_release_policy.json index bf733df4..64d9fc0b 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -113,7 +113,12 @@ "tests/fixtures/extension_theme_colors.json", "docs/japanese_context_reading.md", "LICENSES/TSQYOMI.md", - "LICENSES/TSQYOMI-MIT.txt" + "LICENSES/TSQYOMI-MIT.txt", + "release/japanese_dictionary.json", + "tools/stage_japanese_dictionary.py", + "electron/scripts/build-runtime-resources.cjs", + "electron/electron-builder-code-only.cjs", + "electron/src/main/bundledDictionary.ts" ], "include_roots": [ "characters", diff --git a/tests/test_bundled_japanese_dictionary.py b/tests/test_bundled_japanese_dictionary.py new file mode 100644 index 00000000..66c1f384 --- /dev/null +++ b/tests/test_bundled_japanese_dictionary.py @@ -0,0 +1,162 @@ +"""Application delivery must include its default without owning personal selections.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import sys +import zipfile + +import pytest + +from config.japanese_dictionary import BUNDLED_ENV, PACK_TREE, PACK_ID, load_dictionary_pack, dictionary_pack_root +from config.asset_packages import external_asset_pack_status +from tools.external_assets import build_bundle, install_bundle +from tools.stage_japanese_dictionary import load_pin, stage_bundle, validate_directory +from tools.build_source_release import create_archive, include_bundled_dictionary +from test_japanese_full_resource import _write_pack +from test_japanese_aliases import make_pack, fixture_row, native as native + +ROOT = Path(__file__).resolve().parents[1] +INDEX = ROOT / "assets/index.json" + + +def archive_and_pin(root, output, *, version): + metadata = root / "assets" / PACK_TREE / "provenance.json" + raw = json.loads(metadata.read_bytes()) + raw["version"] = version + metadata.write_text(json.dumps(raw), encoding="utf-8") + archive = output / f"{version}.zip" + manifest = build_bundle(project_root=root, output=archive, pack_ids=[PACK_ID], index_path=INDEX) + pin = {"format": "amadeus.bundled-japanese-dictionary.v1", "pack_id": PACK_ID, + "version": version, "archive": archive.name, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "resource_identity": load_dictionary_pack(metadata.parent).identity, + "files": manifest["files"]} + path = output / f"{version}.json" + path.write_text(json.dumps(pin), encoding="utf-8") + return archive, path + + +def test_stage_and_application_archive_are_pinned_complete_and_deterministic(tmp_path): + publisher = tmp_path / "publisher" + _write_pack(publisher) + archive, pin_path = archive_and_pin(publisher, tmp_path, version="dictionary-1") + staged = stage_bundle(archive, tmp_path / "staged", pin_path=pin_path, index_path=INDEX) + assert stage_bundle(archive, tmp_path / "staged", pin_path=pin_path, index_path=INDEX) == staged + report = {"release_ready": True, "version": "app-2", "source_date_epoch": 1700000000, + "selected_files": [], "selected_file_count": 0} + report = include_bundled_dictionary(report, staged, pin_path=pin_path) + assert report["bundled_dictionary"]["version"] == "dictionary-1" + assert report["distribution_kind"] == "application_with_dictionary" + assert report["selected_file_count"] == 9 + first, second = tmp_path / "app1.zip", tmp_path / "app2.zip" + for target in (first, second): + create_archive(root=publisher, output=target, report=report, policy={}, modes={}, bundled_directory=staged) + assert first.read_bytes() == second.read_bytes() + with zipfile.ZipFile(first) as z: + assert len(z.namelist()) == 10 + assert not any(name.endswith("/user.csv") or name.endswith(".dict") for name in z.namelist()) + z.extractall(tmp_path / "installed") + status = external_asset_pack_status(PACK_ID, asset_root=tmp_path / "installed/amadeus-app-2/assets", index_path=INDEX) + assert status["installed"] and status["source"] == "bundled" and status["version"] == "dictionary-1" + (staged / "source.csv").write_bytes(b"corrupt") + with pytest.raises(ValueError, match="differs"): + validate_directory(staged, load_pin(pin_path)) + with pytest.raises(ValueError, match="changed"): + create_archive(root=publisher, output=tmp_path / "broken.zip", report=report, policy={}, modes={}, bundled_directory=staged) + assert not (tmp_path / "broken.zip").exists() + + +def test_release_gate_rejects_missing_wrong_or_personal_resource(tmp_path): + publisher = tmp_path / "publisher" + _write_pack(publisher) + archive, pin_path = archive_and_pin(publisher, tmp_path, version="dictionary-1") + with pytest.raises(ValueError, match="missing"): + validate_directory(tmp_path / "absent", load_pin(pin_path)) + damaged = tmp_path / "damaged.zip" + damaged.write_bytes(archive.read_bytes() + b"changed") + with pytest.raises(ValueError, match="SHA-256"): + stage_bundle(damaged, tmp_path / "output", pin_path=pin_path, index_path=INDEX) + pin = json.loads(pin_path.read_bytes()) + pin["files"][0]["path"] = "assets/text/ja/user.csv" + pin_path.write_text(json.dumps(pin), encoding="utf-8") + with pytest.raises(ValueError, match="nine pinned"): + load_pin(pin_path) + assert not (tmp_path / "output").exists() + + +def test_application_cli_cannot_silently_omit_the_dictionary(tmp_path, monkeypatch): + from tools import build_source_release as builder + monkeypatch.setattr(builder, "build_report", lambda **kwargs: {"release_ready": True}) + output = tmp_path / "amadeus.zip" + monkeypatch.setattr(sys, "argv", ["build", "--output", str(output)]) + with pytest.raises(SystemExit, match="require --dictionary-bundle"): + builder.main() + monkeypatch.setattr(sys, "argv", ["build", "--output", str(output), "--code-only"]) + with pytest.raises(SystemExit, match="filename must contain code-only"): + builder.main() + assert not output.exists() + + +def test_bundled_default_upgrade_independent_rollback_and_personal_native_loading(tmp_path, native, monkeypatch): + from config import settings + from tts import japanese_dictionary as runtime + monkeypatch.setattr(settings, "ENABLE_JAPANESE_USER_DICTIONARY", True) + monkeypatch.delenv(BUNDLED_ENV, raising=False) + sys.path.insert(0, str(ROOT / "GPT_SoVITS")) + from text import japanese + recipient = tmp_path / "installation" + for version in ("dictionary-1", "dictionary-2"): + publisher = tmp_path / version + make_pack(publisher, [("menu", "メニュー"), ("game", "ゲーム")]) + archive_and_pin(publisher, tmp_path, version=version) + v1, v2 = tmp_path / "dictionary-1.zip", tmp_path / "dictionary-2.zip" + p1, p2 = tmp_path / "dictionary-1.json", tmp_path / "dictionary-2.json" + bundled = stage_bundle(v1, recipient, pin_path=p1, index_path=INDEX) + assert not (recipient / "assets" / PACK_TREE).exists(), "fresh install needs no independent installation step" + first = runtime.configure_japanese_dictionary(native, project_root=recipient) + assert first["ready"] and first["source"] == "bundled" and first["version"] == "dictionary-1" + assert japanese.g2p("menuです。", False) == japanese.g2p("メニューです。", False) + user = recipient / "assets/text/ja/user.csv" + user.parent.mkdir(parents=True, exist_ok=True) + row = fixture_row(native.OpenJTalk(native.OPEN_JTALK_DICT_DIR), "Menu", "メヌー") + row[0], row[3] = "Menu", "-10000" + personal = (",".join(row) + "\n").encode() + user.write_bytes(personal) + stage_bundle(v2, recipient, pin_path=p2, index_path=INDEX, overwrite=True) + second = runtime.configure_japanese_dictionary(native, project_root=recipient) + assert second["source"] == "bundled" and second["version"] == "dictionary-2" + assert first["identity"] != second["identity"] + assert japanese.g2p("menuです。", False) == japanese.g2p("メヌーです。", False) + default_bytes = {p.name: p.read_bytes() for p in bundled.iterdir()} + for archive, version in ((v2, "dictionary-2"), (v1, "dictionary-1")): + install_bundle(archive, project_root=recipient, index_path=INDEX, overwrite=True) + for _ in range(2): + selected = runtime.configure_japanese_dictionary(native, project_root=recipient) + assert selected["ready"] and selected["source"] == "installed" and selected["version"] == version + assert japanese.g2p("menuです。", False) == japanese.g2p("メヌーです。", False) + assert user.read_bytes() == personal + assert {p.name: p.read_bytes() for p in bundled.iterdir()} == default_bytes + independent = recipient / "assets" / PACK_TREE + (independent / "provenance.json").write_text("invalid", encoding="utf-8") + selected = runtime.configure_japanese_dictionary(native, project_root=recipient) + assert selected["version"] != "dictionary-2", "an invalid explicit selection must not silently activate the default" + assert external_asset_pack_status(PACK_ID, asset_root=recipient / "assets", index_path=INDEX)["state"] == "invalid" + + +def test_packaged_resource_path_is_used_without_writing_to_it(tmp_path, native, monkeypatch): + from tts import japanese_dictionary as runtime + publisher = tmp_path / "publisher" + make_pack(publisher, [("menu", "メニュー")]) + archive, pin = archive_and_pin(publisher, tmp_path, version="bundled") + supplied = stage_bundle(archive, tmp_path / "program-files", pin_path=pin, index_path=INDEX) + before = {p.name: p.read_bytes() for p in supplied.iterdir()} + project = tmp_path / "backend" + monkeypatch.setenv(BUNDLED_ENV, str(supplied)) + assert dictionary_pack_root(project / "assets") == supplied + status = runtime.configure_japanese_dictionary(native, project_root=project, enabled=True) + assert status["ready"] and status["source"] == "bundled" + assert external_asset_pack_status(PACK_ID, asset_root=project / "assets", index_path=INDEX)["installed"] + assert {p.name: p.read_bytes() for p in supplied.iterdir()} == before + assert not (supplied / "user.dict").exists() diff --git a/tools/build_source_release.py b/tools/build_source_release.py index 1066d43f..5702b6e7 100644 --- a/tools/build_source_release.py +++ b/tools/build_source_release.py @@ -1,9 +1,10 @@ """Check and assemble a deterministic Amadeus public source archive. -Only files tracked by Git are candidates. The command applies an explicit -allowlist, excludes model/creative/proprietary material, scans filenames and -text without printing secret values, validates third-party provenance, and -writes a SHA-256 manifest. With no ``--output`` it is a non-mutating check. +Source files are selected from Git with an explicit allowlist and provenance +checks. Application archives also require the independently pinned dictionary; +its verified nine files are injected without admitting arbitrary untracked data. +Developer archives must explicitly opt into a code-only name. With no --output, +the command checks source/provenance and any explicitly supplied resource. """ from __future__ import annotations @@ -16,6 +17,7 @@ import subprocess import sys import tomllib +import tempfile import zipfile from datetime import datetime, timezone from pathlib import Path @@ -381,6 +383,18 @@ def _zip_timestamp(source_date_epoch: int) -> tuple[int, int, int, int, int, int return (value.year, value.month, value.day, value.hour, value.minute, value.second) +def include_bundled_dictionary(report: dict, directory: Path, *, pin_path: Path) -> dict: + from tools.stage_japanese_dictionary import load_pin, validate_directory + pin = load_pin(pin_path) + bundled = validate_directory(directory, pin) + if {r["path"] for r in report["selected_files"]} & {r["path"] for r in bundled}: + raise ValueError("Generated dictionary data must not also be tracked application source") + return {**report, "distribution_kind": "application_with_dictionary", + "selected_files": sorted([*report["selected_files"], *bundled], key=lambda r: r["path"]), + "selected_file_count": report["selected_file_count"] + len(bundled), + "bundled_dictionary": {key: pin[key] for key in ("version", "sha256", "resource_identity")}} + + def create_archive( *, root: Path, @@ -388,6 +402,7 @@ def create_archive( report: dict[str, Any], policy: dict[str, Any], modes: dict[str, str], + bundled_directory: Path | None = None, ) -> None: if not report.get("release_ready"): raise ValueError("refusing to build an archive that did not pass the release gate") @@ -412,7 +427,15 @@ def create_archive( info.compress_type = zipfile.ZIP_DEFLATED permissions = 0o755 if modes.get(relative) == "100755" else 0o644 info.external_attr = (permissions & 0xFFFF) << 16 - archive.writestr(info, (root / relative).read_bytes(), compresslevel=9) + if record.get("origin") == "bundled_dictionary": + if bundled_directory is None: + raise ValueError("Bundled dictionary input is required") + data = (bundled_directory / Path(relative).name).read_bytes() + if hashlib.sha256(data).hexdigest() != record["sha256"]: + raise ValueError("Bundled dictionary changed during archive creation") + else: + data = (root / relative).read_bytes() + archive.writestr(info, data, compresslevel=9) manifest_bytes = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode( TEXT_DECODING ) @@ -445,6 +468,8 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="inspect a dirty tree without making it release-ready; never permits archive creation", ) + parser.add_argument("--dictionary-bundle", type=Path, help="pinned independent dictionary ZIP for application archives") + parser.add_argument("--code-only", action="store_true", help="explicit developer/CI archive; not an application distribution") parser.add_argument("--overwrite", action="store_true") parser.add_argument("--max-issues", type=int, default=40) return parser @@ -462,41 +487,60 @@ def main() -> int: policy=policy, allow_dirty_check=bool(args.allow_dirty_check), ) - if args.manifest_output: - _write_json(Path(args.manifest_output).resolve(), report, overwrite=bool(args.overwrite)) - - errors = [item for item in report["issues"] if item["severity"] == "error"] - warnings = [item for item in report["issues"] if item["severity"] == "warning"] - print( - "source release check: " - f"{'passed' if report['release_ready'] else 'blocked'}; " - f"{report['selected_file_count']} selected, {report['excluded_file_count']} excluded, " - f"{len(errors)} errors, {len(warnings)} warnings" - ) - for item in report["issues"][: max(args.max_issues, 0)]: - location = item.get("path", "") - if item.get("line") is not None: - location += f":{item['line']}" - component = f" [{item['component']}]" if item.get("component") else "" - suffix = f" ({location})" if location else "" - print(f"- {item['severity'].upper()} {item['rule']}{component}{suffix}: {item['message']}") - remaining = len(report["issues"]) - max(args.max_issues, 0) - if remaining > 0: - print(f"- ... {remaining} more issues; use --manifest-output for the full report") - - if args.output: - output = Path(args.output).resolve() - if output.exists() and not args.overwrite: - raise FileExistsError(f"output already exists (pass --overwrite): {output}") - create_archive( - root=ROOT, - output=output, - report=report, - policy=policy, - modes=tracked_file_modes(ROOT), + bundled_directory = None + staging = tempfile.TemporaryDirectory(prefix="amadeus-source-bundle-") + try: + if not args.code_only and (args.output or args.dictionary_bundle): + from tools.stage_japanese_dictionary import stage_bundle + if args.dictionary_bundle is None: + raise SystemExit("Application archives require --dictionary-bundle ; use --code-only for developer archives") + bundled_directory = stage_bundle(args.dictionary_bundle, Path(staging.name)) + report = include_bundled_dictionary( + report, bundled_directory, pin_path=ROOT / "release/japanese_dictionary.json") + elif args.code_only: + if args.dictionary_bundle: + raise SystemExit("--code-only cannot be combined with --dictionary-bundle") + if args.output and "code-only" not in Path(args.output).name: + raise SystemExit("Developer archive filename must contain code-only") + report["distribution_kind"] = "code_only_not_application_release" + if args.manifest_output: + _write_json(Path(args.manifest_output).resolve(), report, overwrite=bool(args.overwrite)) + + errors = [item for item in report["issues"] if item["severity"] == "error"] + warnings = [item for item in report["issues"] if item["severity"] == "warning"] + print( + "source release check: " + f"{'passed' if report['release_ready'] else 'blocked'}; " + f"{report['selected_file_count']} selected, {report['excluded_file_count']} excluded, " + f"{len(errors)} errors, {len(warnings)} warnings" ) - print(f"archive: {output}") - return 0 if report["release_ready"] else 1 + for item in report["issues"][: max(args.max_issues, 0)]: + location = item.get("path", "") + if item.get("line") is not None: + location += f":{item['line']}" + component = f" [{item['component']}]" if item.get("component") else "" + suffix = f" ({location})" if location else "" + print(f"- {item['severity'].upper()} {item['rule']}{component}{suffix}: {item['message']}") + remaining = len(report["issues"]) - max(args.max_issues, 0) + if remaining > 0: + print(f"- ... {remaining} more issues; use --manifest-output for the full report") + + if args.output: + output = Path(args.output).resolve() + if output.exists() and not args.overwrite: + raise FileExistsError(f"output already exists (pass --overwrite): {output}") + create_archive( + root=ROOT, + output=output, + report=report, + policy=policy, + modes=tracked_file_modes(ROOT), + bundled_directory=bundled_directory, + ) + print(f"archive: {output}") + return 0 if report["release_ready"] else 1 + finally: + staging.cleanup() if __name__ == "__main__": diff --git a/tools/probes/verify_bundled_dictionary.py b/tools/probes/verify_bundled_dictionary.py new file mode 100644 index 00000000..30437583 --- /dev/null +++ b/tools/probes/verify_bundled_dictionary.py @@ -0,0 +1,76 @@ +"""Verify the selected release dictionary through the real Japanese production frontend. + +Run this from an extracted application archive with native voice dependencies +installed. It compiles only disposable caches; it does not install/publish data. +""" +from __future__ import annotations +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "GPT_SoVITS")] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected-source", choices=("bundled", "installed"), default="bundled") + parser.add_argument("--expected-version") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + import pyopenjtalk + from config import settings + from config.asset_packages import external_asset_pack_status + from config.japanese_dictionary import PACK_ID, dictionary_pack_root, load_dictionary_pack + from text import japanese + from text.cleaner import clean_text + from tools.tts_text_processor import correct_pronunciation_for_tts + from tools.probes.japanese_pronunciation_evidence import target_readings + from tts.japanese_dictionary import configure_japanese_dictionary, prepare_japanese_text + from tts.japanese_reading import configure_japanese_reading + if not settings.ENABLE_JAPANESE_USER_DICTIONARY: + raise RuntimeError("Default dictionary setting is disabled in the acceptance environment") + pin = json.loads((ROOT / "release/japanese_dictionary.json").read_bytes()) + expected_version = args.expected_version or pin["version"] + status = configure_japanese_dictionary(pyopenjtalk, project_root=ROOT) + configure_japanese_reading(pyopenjtalk, enabled=False) + if not status["ready"] or status["source"] != args.expected_source or status["version"] != expected_version: + raise RuntimeError(f"Wrong loaded dictionary: {status}") + advertised = external_asset_pack_status(PACK_ID, asset_root=ROOT / "assets", index_path=ROOT / "assets/index.json") + assert advertised["installed"] and advertised["source"] == status["source"] and advertised["version"] == status["version"] + pack = load_dictionary_pack(dictionary_pack_root(ROOT / "assets")) + if args.expected_source == "bundled": + assert pack.identity == pin["resource_identity"] + original = japanese.run_frontend + nodes, cases = [], [] + def observed(jtalk, text): + result = original(jtalk, text) + nodes.extend(result) + return result + japanese.run_frontend = observed + try: + for word, reading, sentence in [("menu", "メニュー", "menuを開いてください。"), + ("game", "ゲーム", "このgameを始めましょう。"), + ("Disney", "ディズニー", "Disneyの映画を見ました。"), + ("中立派", "チュウリツハ", "彼は中立派です。")]: + nodes.clear() + phones = clean_text(correct_pronunciation_for_tts(sentence), "ja", "v2")[0] + target = prepare_japanese_text(correct_pronunciation_for_tts(word)).strip() + actual = target_readings(nodes, target) + assert actual == [reading], (word, actual) + cases.append({"word": word, "reading": actual, "phones": phones}) + finally: + japanese.run_frontend = original + pyopenjtalk.unset_user_dict() + report = {"runtime": status, "source_rows": pack.entry_count, "reviewed_corrections": pack.reviewed_rows, + "resource_identity": pack.identity, "cases": cases, "audio_rendered": False} + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"source": status["source"], "version": status["version"], "source_rows": pack.entry_count, + "case_matches": len(cases)}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/tools/stage_japanese_dictionary.py b/tools/stage_japanese_dictionary.py new file mode 100644 index 00000000..66d75014 --- /dev/null +++ b/tools/stage_japanese_dictionary.py @@ -0,0 +1,106 @@ +"""Stage the pinned dictionary for application distribution; never install into user data.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from config.japanese_dictionary import BUNDLED_TREE, PACK_TREE, PACK_ID, PACK_FILES, FULL_SOURCE_FILES, load_dictionary_pack +from tools.external_assets import install_bundle + +PIN_PATH = ROOT / "release/japanese_dictionary.json" + + +def load_pin(path: Path = PIN_PATH) -> dict: + pin = json.loads(path.read_bytes()) + if (not isinstance(pin, dict) or pin.get("format") != "amadeus.bundled-japanese-dictionary.v1" + or pin.get("pack_id") != PACK_ID or not pin.get("version") + or not re.fullmatch(r"[a-f0-9]{64}", pin.get("sha256", "")) + or not re.fullmatch(r"[a-f0-9]{64}", pin.get("resource_identity", ""))): + raise ValueError("Invalid bundled Japanese dictionary pin") + expected = {f"assets/{PACK_TREE}/{name}" for name in PACK_FILES + FULL_SOURCE_FILES} + files = pin.get("files", []) + if (not isinstance(files, list) or not all(isinstance(r, dict) for r in files) + or len(files) != len(expected) or {r.get("path") for r in files} != expected + or any(not re.fullmatch(r"[a-f0-9]{64}", r.get("sha256", "")) + or not isinstance(r.get("size"), int) or r["size"] < 1 for r in files)): + raise ValueError("Bundled dictionary must contain exactly the nine pinned resource files") + return pin + + +def validate_directory(directory: Path, pin: dict) -> list[dict]: + expected = {Path(record["path"]).name for record in pin["files"]} + if not directory.is_dir() or {p.name for p in directory.iterdir()} != expected: + raise ValueError("Bundled Japanese dictionary is missing or has unexpected files; stage the pinned resource first") + records = [] + for entry in pin["files"]: + file = directory / Path(entry["path"]).name + data = file.read_bytes() + if len(data) != entry["size"] or hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise ValueError(f"Bundled Japanese dictionary differs from release pin: {file.name}") + records.append({"path": f"assets/{BUNDLED_TREE}/{file.name}", "size": len(data), + "sha256": entry["sha256"], "origin": "bundled_dictionary"}) + pack = load_dictionary_pack(directory) + if pack.version != pin["version"] or pack.identity != pin["resource_identity"]: + raise ValueError("Bundled dictionary version/identity differs from release pin") + return sorted(records, key=lambda entry: entry["path"]) + + +def stage_bundle(archive: Path, output_root: Path, *, pin_path: Path = PIN_PATH, + index_path: Path = ROOT / "assets/index.json", overwrite: bool = False) -> Path: + pin = load_pin(pin_path) + if hashlib.sha256(archive.read_bytes()).hexdigest() != pin["sha256"]: + raise ValueError("Japanese dictionary archive SHA-256 differs from release pin") + with tempfile.TemporaryDirectory(prefix="amadeus-bundled-dictionary-") as temp: + source_root = Path(temp) + installed = install_bundle(archive, project_root=source_root, index_path=index_path) + if installed["packs"] != [PACK_ID]: + raise ValueError("Application dictionary input must contain only japanese-pronunciation") + source = source_root / "assets" / PACK_TREE + records = validate_directory(source, pin) + target = output_root / "assets" / BUNDLED_TREE + if target.exists(): + try: + validate_directory(target, pin) + return target + except ValueError: + if not overwrite: + raise FileExistsError("Staged dictionary differs; use a fresh output root or --overwrite") from None + if not {p.name for p in target.iterdir()} <= {Path(r["path"]).name for r in records}: + raise ValueError("Refusing to overwrite a staging directory containing unrelated files") from None + target.mkdir(parents=True, exist_ok=True) + for record in records: + name = Path(record["path"]).name + shutil.copyfile(source / name, target / name) + validate_directory(target, pin) + return target + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path) + parser.add_argument("--output-root", type=Path, default=ROOT / "build/bundled-assets") + parser.add_argument("--pin", type=Path, default=PIN_PATH) + parser.add_argument("--check", type=Path, help="verify an existing bundled dictionary directory") + parser.add_argument("--overwrite", action="store_true") + args = parser.parse_args() + if args.check: + records = validate_directory(args.check, load_pin(args.pin)) + print(json.dumps({"files": len(records), "valid": True})) + elif args.archive: + print(stage_bundle(args.archive, args.output_root, pin_path=args.pin, overwrite=args.overwrite)) + else: + parser.error("provide --archive or --check") + + +if __name__ == "__main__": + main() diff --git a/tts/japanese_dictionary.py b/tts/japanese_dictionary.py index 66da784a..09f6a303 100644 --- a/tts/japanese_dictionary.py +++ b/tts/japanese_dictionary.py @@ -13,7 +13,7 @@ from config.asset_paths import PROJECT_ROOT from config.japanese_dictionary import ( - KANJI_COMPOUND, LATIN_WORD_BOUNDARY, PACK_TREE, dictionary_rows, latin_word_key, load_dictionary_pack, + KANJI_COMPOUND, LATIN_WORD_BOUNDARY, PACK_TREE, dictionary_rows, latin_word_key, load_dictionary_pack, dictionary_pack_root, ) logger = logging.getLogger(__name__) @@ -198,6 +198,7 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e _official_compound_paths = {} _official_base_owner = None state, version, identity = "invalid", "", None + source_kind = "base" detail = "Optional Japanese dictionary unavailable." reset = None try: @@ -219,7 +220,7 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e compiled, digests, issues = [], [], [] data_review = None cache = project_root / ".cache/japanese_dictionary" - official = project_root / "assets" / PACK_TREE / "userdict.csv" + official = dictionary_pack_root(project_root / "assets") / "userdict.csv" legacy = project_root / "GPT_SoVITS/text/ja_userdic/userdict.csv" personal = project_root / "assets/text/ja/user.csv" sources = [] @@ -231,7 +232,8 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e if personal.is_file(): sources.append(personal) for source in sources: - label = source.relative_to(project_root).as_posix() + label = (source.relative_to(project_root).as_posix() if source.is_relative_to(project_root) + else f"bundled/{source.name}") pending_aliases = {} pending_review = None key = None @@ -265,6 +267,7 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e _official_katakana_aliases = pending_aliases digests.append(pack.identity) version, data_review = pack.version, pending_review + source_kind = "installed" if official.parent == project_root / "assets" / PACK_TREE else "bundled" continue else: official_words = frozenset(latin_word_key(row[0]) for row in dictionary_rows(data) @@ -321,12 +324,16 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e digests.append(pack.identity if pack is not None else digest) if pack is not None: version = pack.version + source_kind = "installed" if official.parent == project_root / "assets" / PACK_TREE else "bundled" data_review = pending_review _official_latin_words = official_words _official_katakana_aliases = pending_aliases elif source == legacy: version = "legacy-local" + source_kind = "legacy" if source == personal: + if source_kind == "base": + source_kind = "personal" # Explicit personal spellings retain ownership of their input. personal_keys = {latin_word_key(row[0]) for row in dictionary_rows(data)} _official_latin_words = _official_latin_words - personal_keys @@ -380,7 +387,7 @@ def configure_japanese_dictionary(jtalk, *, project_root: Path = PROJECT_ROOT, e logger.warning("[JapaneseDictionary] Native reset failed: %s", cleanup) logger.warning("[JapaneseDictionary] Optional dictionary unavailable: %s", exc) _runtime_status = {"state": state, "ready": state == "ready", "version": version, - "identity": identity, "detail": detail} + "identity": identity, "detail": detail, "source": source_kind} from tts.japanese_reading import configure_japanese_reading configure_japanese_reading(jtalk, project_root=project_root) return dictionary_runtime_status() From c109fe519942c0b429e10ef77d271e603c4f00d0 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 06:50:17 +1300 Subject: [PATCH 2/2] Record full-resource bundled installation and rollback acceptance --- .../japanese_bundled_delivery_2026-10-22.json | 102 ++++++++++++++++++ docs/japanese_pronunciation_dictionary.md | 8 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/japanese_bundled_delivery_2026-10-22.json diff --git a/docs/evidence/japanese_bundled_delivery_2026-10-22.json b/docs/evidence/japanese_bundled_delivery_2026-10-22.json new file mode 100644 index 00000000..e28c2e2c --- /dev/null +++ b/docs/evidence/japanese_bundled_delivery_2026-10-22.json @@ -0,0 +1,102 @@ +{ + "implementation_commit": "92d45b8a676689dbda6d01eb8a4575a6b7158ab4", + "resource": { + "version": "2026.10.22.1-rc2", + "sha256": "a935b3adfded9022afe3b2384cebc2ef1e4023f54f48df1f9ded9ff45b896989", + "resource_identity": "298576bfaf6e553f55631b2bbbea8099deb9b3919903bce8f646ca2ecd791c46" + }, + "application_archive": { + "sha256": "c1761b41baa005cc96c8556c1fe1511ffa845a70cc24bd944ee35418fbcd971c", + "bytes": 27620745 + }, + "cases": { + "fresh-native": { + "source": "bundled", + "version": "2026.10.22.1-rc2", + "source_rows": 134201, + "case_matches": 4 + }, + "before-app-upgrade": { + "source": "bundled", + "version": "2026.10.20.3", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "after-app-upgrade": { + "source": "bundled", + "version": "2026.10.22.1-rc2", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "independent-install": { + "source": "installed", + "version": "2026.10.20.3", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "independent-update": { + "source": "installed", + "version": "2026.10.22.1-rc2", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "independent-rollback": { + "source": "installed", + "version": "2026.10.20.3", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "rollback-after-app-upgrade": { + "source": "installed", + "version": "2026.10.20.3", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "return-to-bundled": { + "source": "bundled", + "version": "2026.10.22.1-rc2", + "personal_reading": "メヌー", + "personal_reading_match": true + }, + "electron-native": { + "source": "bundled", + "version": "2026.10.22.1-rc2", + "source_rows": 134201, + "case_matches": 4 + } + }, + "bundled_member_count": 9, + "personal_and_settings_preserved": true, + "live_install_unchanged": true, + "electron_resource_members_verified": 9, + "audio_rendered": false, + "merged": false, + "published": false, + "scope": "Bundled application delivery; resource bytes and pronunciation rules are unchanged from the accepted rc2.", + "tests": { + "python_combined": { + "passed": 166 + }, + "final_archive_targeted": { + "passed": 19 + }, + "electron_unit": { + "passed": 537, + "failed": 1, + "limitation": "Existing localhost:5173 listener caused EADDRINUSE in the Vite development-server integration test; it was left running." + }, + "electron_build": "passed", + "electron_full_unpacked_package": "passed", + "electron_code_only_unpacked_package": "passed", + "core_plus_pinned_japanese_frontend": "passed", + "extracted_source_electron_staging": "passed" + }, + "limitations": [ + "Windows artifact lifecycle qualification; native contract CI is configured on Windows, Linux and macOS.", + "Actual native Japanese frontend output was checked, not newly synthesized audio; dictionary bytes are unchanged from prior listening acceptance.", + "Source extraction and Electron resource handoff were verified. This is not a claim that the Electron installer independently installs the Python environment.", + "Existing independent dictionary installs remain selected across application upgrades, including older format-1 versions.", + "The resource download URL remains unset; full cloud release packaging requires an approved HTTPS input matching the committed hash.", + "No application version bump, release tag, merge or publication is authorized by this receipt. Wait for the console UI/UX work and combined acceptance." + ] +} diff --git a/docs/japanese_pronunciation_dictionary.md b/docs/japanese_pronunciation_dictionary.md index 1f63396c..bc37bd42 100644 --- a/docs/japanese_pronunciation_dictionary.md +++ b/docs/japanese_pronunciation_dictionary.md @@ -12,7 +12,7 @@ supplement contains 4,539 rows / 4,537 words; it supplements the full table. Preservation and automated screening do not establish pronunciation accuracy. Release 0.17.1 was withdrawn. Candidate **2026.10.22.1-rc2** remains unpublished -pending independent review and maintainer acceptance. See the +pending combined application-release acceptance. See the [current follow-up](evidence/japanese_resource_personal_case_2026-10-22.json). The [2026-10-21 receipt](evidence/japanese_resource_acceptance_2026-10-21.json) is historical and superseded. @@ -245,3 +245,9 @@ resource identity and menu/game/Disney/中立派 through the production text fro This is loading/regression evidence, not a new acoustic or full-vocabulary accuracy claim. CI also exercises synthetic fresh-install, default-upgrade, independent update/rollback, personal preservation and packaging rejection contracts. + +The [bundled-delivery receipt](evidence/japanese_bundled_delivery_2026-10-22.json) +records actual full-resource extraction, upgrade, independent update/rollback, +personal-data preservation and Electron resource loading. It binds the tested +implementation commit and archive hash; later documentation-only commits do not +change the qualified resource bytes. No new application release is approved.