Skip to content

Feat/sandhi expansion#233

Merged
GJRobert merged 4 commits into
mainfrom
feat/sandhi-expansion
Jul 18, 2026
Merged

Feat/sandhi expansion#233
GJRobert merged 4 commits into
mainfrom
feat/sandhi-expansion

Conversation

@GJRobert

Copy link
Copy Markdown
Collaborator

No description provided.

GJRobert and others added 3 commits July 17, 2026 19:26
## Phase A:重構大埔(零行為變更)

- `getDapuSandhiHtml()` → `getSandhiHtml(html, dialectCode)`:
  移除三條硬編碼 regex,改為查表驅動
- 新增 `classifyTone(syllable, dialectCode)`:
  - NFD 正規化剝離變音符 → 查 `reverse_tone_mapping.json` 回傳調值
  - 入聲判定:`/b$|d$/` 或 `/g$/ && !endsWith('ng')`(修正 `-ng` 鼻音尾誤判為入聲)
- 新增 `sandhi_rules.json`,`da`(大埔)三條規則:
  - `53 when 31/53/54/21 → 55`(補入後字陰入 54、陽入 21)
  - `33 when 113/31/21 → 35`(補入後字陽入 21)
  - `113 when 113 → 33`
- CSS class 一般化:`sandhi-高降變/中平變/低升變` → `sandhi-t{調值}`
  (大埔:t55 紅、t35 紫、t33 藍;顏色不變,保證像素級回歸)
- `main.js` 載入清單補 `reverse_tone_mapping.json`、`sandhi_rules.json`
- 壓測 25,635 筆大埔拼音:25,625 筆完全吻合,10 筆差異全為舊 regex 幽靈變調已修正

## Phase B:海陸腔

- `sandhi_rules.json` 新增 `ha`:上聲 24→33(無條件)、陰入 5→2(無條件)
- CSS 新增 `sandhi-t2`(綠色;亮:`forestgreen`,暗:`#A5D6A7`)
- `main.js` `getSandhiPronunciation()` 新增 `dialect.includes('海陸')` → `'ha'`
- 主列表渲染解除寫死 `'da'`,動態依腔別傳入 `dialectCode`

## Phase C:四縣腔(含南四縣)

- `sandhi_rules.json` 新增 `si`:陰平 24 後接 24/55/5 → 11
- CSS 新增 `sandhi-t11`(橘色;亮:`darkorange`,暗:`#FFB74D`)
- `main.js` 新增 `dialect.includes('四縣')` → `'si'`

## 拼音 Regex 搜尋

- 搜尋欄新增「拼音 regex 模式」:含 regex 元字元且只由拼音字元組成時,
  對 `客語標音_查詢`(數字調字串)做 `RegExp.test()`
- 用途:`.+53 .+53`(相連兩音節)方便測試變調視覺;無效 regex 安靜回空,不爆

## 快取

- `data/data_version.json` → `2026071704`(每次改 JSON 必須 bump,已被坑兩次)
- `index.html` `?v=4.4.5`

AI 分工:
- 可行性評估(5 腔難度排序):Claude Fable 5
- 施工書(引擎架構、鐵則、各腔規則):Opus 4.8
- Phase A 實作:Gemini 3.1 Pro
- Phase A bug 修正(快取未刷新、-ng 誤判、規則補全)、審查:Sonnet 4.6
- Phase B/C 實作:Gemini 3.1 Pro

Co-authored-by: Claude Fable 5 <Fable5@ClaudeCode>
Co-authored-by: Opus 4.8 <Opus4.8@ClaudeCode>
Co-authored-by: Claude Sonnet 4.6 <Sonnet4.6@Antigravity>
Co-authored-by: Gemini 3.1 Pro <Gemini-3-1-Pro@Antigravity>
Signed-off-by: GHSRobert <robertus0617@gmail.com>
## Phase D:詔安腔(zh)

- `sandhi_rules.json` 新增 `zh`,六條規則:
  - `31 when 31 → 55`,`else → 11`
  - `24 always → 5`(有 -b/-d 止聲韻尾的陰入)
  - `24v always → 55`(-g 尾失落、純母音結尾的陰入)
  - `53 always → 33`、`55 always → 33`、`43 always → 3`
- `reverse_tone_mapping.json`:詔安 `zh` 補 `"á": "24v"` sentinel,
  區分「有 stop 陰入 → 5」與「-g 尾失落陰入 → 55」
- CSS 新增 `sandhi-t5`(藍綠 `teal` / `#4DB6AC`)、`sandhi-t3`(咖啡 `saddlebrown` / `#D7CCC8`)

## Phase E:饒平腔(rh,僅新竹音)

- `sandhi_rules.json` 新增 `rh`,四條規則:
  - `53 when 11/53 → 33`,`else → 11`
  - `55 when 11/53/2 → 53`,`else → 33`
  - `24 always → 33`、`2 always → 5`
- **【】括號防護**(`inBracket` 狀態機):`dialectCode === 'rh'` 時跳過
  `【】` 內的卓蘭/桃園音,不套新竹規則;圓括號 `()()` 照常變調
- CSS 新增 `sandhi-t53`(紫紅 `mediumvioletred` / `#F48FB1`)

## 判調修正:鼻音音節可變調(#4)

- `classifyTone()` 重構:捨棄逐字元 `vowel_map` 比對,改用 NFD 拆解
  直接提取五個客話調號標記(`́/̀/̌/̂/̄`)
  + 獨立注音符號(ˊˋˇˆ¯)一律正規化 → 支援 `m̌`/`ňg`/`ǹg` 等鼻音音節

## 遊戲 regression 修正(Phase D 副作用)

- `js/game/game-ui.js`:`formatGamePinyinWithSandhi()` 移除寫死的
  `typeof getDapuSandhiHtml` 檢查,改為動態推算 `dialectCode`(da/ha/si/zh/rh)
  再呼叫泛用 `getSandhiHtml`;補上饒平 `prefix==='饒'/'平'` 對應

## 官方例詞 fixture 回歸測試(#1/#2)

- 新增 `tests/sandhi_fixture_test.js`:五腔 HakPinPlus 格式例詞 + 預期輸出,
  對真實引擎跑 `getSandhiHtml`;全數通過
- `main.js` 末尾補 `module.exports { classifyTone, getSandhiHtml, formatPhoneticForDisplay }`,
  讓測試腳本可直接 `require`(瀏覽器端 `typeof module` 守衛不影響)

## 快取

- `data_version.json` → `2026071706`、`index.html ?v=4.4.8`

AI 分工:
- Phase D/E 實作、遊戲 regression 修正、fixture 測試、鼻音修正:Gemini 3.1 Pro
- 全腔總審查(規則逐條對官方文件、判調覆蓋率、鐵則 2 大埔零行為變更):Opus 4.8

Co-authored-by: Gemini 3.1 Pro <Gemini-3-1-Pro@Antigravity>
Co-authored-by: Opus 4.8 <Opus4.8@ClaudeCode>
Signed-off-by: GHSRobert <robertus0617@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review: Sandhi Expansion (#233)

Nice piece of work — generalizing the Dapu-only tone sandhi engine (getDapuSandhiHtml) into a rule-driven, data-configured engine (getSandhiHtml + sandhi_rules.json + classifyTone) that now covers all five major dialects (大埔/海陸/四縣/詔安/饒平) is a solid architectural improvement. Moving from regex-per-tone-color matching to a declarative tone-transition table is much more maintainable and testable. The new tests/sandhi_fixture_test.js with dialect-specific fixtures (including a bracket-guard case for 饒平 and a no-sandhi nasal case for 詔安) is a good sign — several fixtures were traced by hand against classifyTone/sandhi_rules.json and they check out.

Code quality / maintainability

  • Dialect-code mapping is duplicated ~5 times. The dialect name → dialectCode if/else chain ('大埔'→'da', '海陸'→'ha', …) is re-implemented independently in getSandhiPronunciation (main.js:1057), three inline call sites in main.js (~2940, ~4409, ~6145), and again in formatGamePinyinWithSandhi (js/game/game-ui.js:362), each with slightly different source fields (selectedDialect, dialectInfo.腔, gameActiveDataVarName/gameActiveDialect prefix matching). A shared helper (e.g. getDialectCodeFromName(str)) would remove this duplication and prevent the five copies from silently drifting apart the next time a dialect is added or renamed.
  • Stale guard in classifyTone (main.js:871): it still requires window.reverseToneMappingData.vowel_map to exist, but the comment right above explains vowel_map character-matching was deliberately abandoned in favor of NFD tone-mark extraction. vowel_map is otherwise unused in this function now, so the guard is misleading dead-code-adjacent logic — worth dropping or updating the comment to explain why it's still checked.
  • applySandhiToGenerated (renamed from applyDapuSandhiToGenerated) is hardcoded to 'da' with a comment explaining it's legacy-specific — reasonable as a deliberate scoping choice, but slightly odd next to a function whose new name no longer signals that limitation. Consider keeping Dapu in the name or passing the dialect in, since the generic name reads as if it now supports all dialects like its sibling getSandhiHtml.
  • Minor: index.html bumps game-ui.js to ?v=4.6.11 even though (per the diff) only one functional edit landed in that file — not wrong, just flagging in case it was meant to track something between 4.6.9 and 4.6.11 that didn't make it into this PR.

Potential bugs / correctness

  • classifyTone's diacritic normalization uses .replace('ˊ', '́') etc. with plain-string (non-global) String.replace, which only replaces the first match. This is fine as long as a syllable only ever carries a single tone mark (true for the sample data), but it's a latent trap if this function is ever reused on multi-syllable strings without pre-splitting — worth a comment noting the single-tone-mark assumption, or switch to a /g regex to be safe.
  • The new bracket-skip guard (inBracket && dialectCode === 'rh', main.js:997) only suppresses sandhi inside 【...】 for the 饒平 dialect. If this bracket convention (e.g., marking a repeated/quoted phrase) applies to source data for other dialects too, they'd still get (possibly incorrect) sandhi applied inside brackets. If this is intentionally 饒平-specific per the source dictionary's formatting conventions, a one-line comment explaining why it's scoped that way would help — otherwise it reads like an oversight.

Search feature (looksLikePhoneticRegex)

  • Building a RegExp directly from raw user input (new RegExp(keyword, 'i')) is wrapped in try/catch, which correctly avoids a hard error on invalid patterns — good defensive touch.
  • Since this only runs against the user's own locally-loaded combinedData in their own browser (triggered on Enter/radio-change, not per-keystroke — confirmed there's no input-event wiring driving it), there's no cross-user security exposure. The only residual risk is a user pasting a pathological catastrophic-backtracking pattern and locking up their own tab; not worth guarding against given the trigger is explicit (Enter key), but flagging for awareness.

Test coverage

  • tests/sandhi_fixture_test.js is a nice addition, and its manual Node harness (mocking document/window) is appropriately lightweight given there's no existing test runner/package.json in the repo. Consider wiring it into CI (even a minimal GitHub Actions step running node tests/sandhi_fixture_test.js and checking the exit code) so regressions in sandhi_rules.json or classifyTone get caught automatically instead of relying on manual runs.
  • Fixture coverage is good breadth-wise (one per dialect plus a couple of edge cases), but there's no fixture for the newly-added looksLikePhoneticRegex search path or for the bracket-guard interacting with a non-饒平 dialect — small gaps, not blocking.

Data/versioning

  • data_version.json, style.css, and the script query-string version bumps look consistent with the project's existing cache-busting convention.

Overall this is a well-structured refactor that meaningfully expands functionality while improving the underlying design (data-driven rules > hardcoded regex-per-dialect). The main actionable item is de-duplicating the dialect-code mapping logic; everything else here is a minor polish suggestion.

依 PR #233 bot review 裁示(7 項選 5 做):

## #1 去重 getDialectCode

新增 `getDialectCode(dialect)` 函式(`main.js`),單一入口吃三種輸入形狀:
- 完整腔名字串(`includes`)
- 單一腔字(大/海/四/南/平/安)
- 遊戲資料變數前綴(含「饒」)

收掉五處各自為政的 if/else:`getSandhiPronunciation`、主表渲染、
`createComparisonRow`×2、`game-ui.js` 的 `formatGamePinyinWithSandhi`。
(表名解析器 `tableName.startsWith` 用途不同,未動。)

## #6 inBracket 補說明

`getSandhiHtml` 中 `inBracket && dialectCode==='rh'` 上方補三行說明:
饒平限定、【】以外照變、保護卓蘭/桃園音的設計理由。

## #2 vowel_map 守衛補說明

`classifyTone` 開頭的 `vowel_map` 存在性守衛補說明:
已改走 NFD 判調,`vowel_map` 此處僅作「資料已載入」哨兵用。

## #5 .replace 非全域補說明

NFD 鏈中各 `.replace` 補說明:呼叫端一律單音節,故不需 `/g`。

## CI:fixture test 自動跑

新增 `.github/workflows/sandhi-test.yml`:
push/PR 動到 `main.js`/`sandhi_rules.json`/`reverse_tone_mapping.json`/
fixture 時觸發,跑 `node tests/sandhi_fixture_test.js`(17/17 綠才過)。

## 快取

- `main.js?v=4.4.8 → 4.4.9`、`game-ui.js?v=4.6.11 → 4.6.12`
- 無 data 異動,`data_version` 不需 bump

引擎邏輯零改動;`getDialectCode` 等價性已在 fixture tests 驗過。

Co-authored-by: Opus 4.8 <Opus4.8@ClaudeCode>
Signed-off-by: GHSRobert <robertus0617@gmail.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying hakspring with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7d47fad
Status: ✅  Deploy successful!
Preview URL: https://c883852c.hakspring.pages.dev
Branch Preview URL: https://feat-sandhi-expansion.hakspring.pages.dev

View logs

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review: Feat/sandhi expansion (#233)

Nice piece of work — generalizing the Dapu-only tone-sandhi engine into a data-driven, multi-dialect (da/ha/si/zh/rh) system is a big improvement over the old hardcoded regex/if-chain, and shipping a CI-gated fixture test (tests/sandhi_fixture_test.js + .github/workflows/sandhi-test.yml) for a project that previously had almost no automated test coverage is a genuine quality upgrade. A few things worth a look before/after merge:

Correctness

  • typeof window !== 'undefined' guard doesn't actually distinguish "browser" from "Node test run" (main.js:6532-6543). tests/sandhi_fixture_test.js does global.window = {...} before require('../main.js'), so inside main.js window is defined and initializeApp() still gets invoked during the test run. initializeApp() immediately touches history (undeclared in the test stub) and DOM APIs the stub doesn't implement, so it will throw/reject. It currently doesn't fail the test suite only because the throw happens inside an async function (so it becomes an unhandled rejection) and the test script runs entirely synchronously to process.exit() before Node gets a chance to report it — that's incidental, not by design, and could start failing CI on a future Node version that reports/exits on unhandled rejections more eagerly, or the moment someone adds an await earlier in the test file. Consider guarding on something Node-specific instead (e.g. typeof module === 'undefined' || !module.exports), or explicitly skip via an env flag rather than relying on window being absent.

  • sandhi_rules.json "24v" tone code for 詔安 (zh) has no direct fixture coverage. reverse_tone_mapping.json adds "á": "24v" and sandhi_rules.json adds a "24v": { "always": "55" } rule, but every zh fixture that could exercise "24v" as the front syllable (e.g. a bare , no stop coda) only appears in the next-syllable position of an "always" rule, where the next tone is never actually consulted. So the fixture suite currently can't catch a regression that swaps or drops the 24 vs 24v distinction. Worth adding one case with (no coda) as the leading syllable under zh.

Code quality

  • applySandhiToGenerated() (renamed from applyDapuSandhiToGenerated, main.js:1101) is still hardcoded to 'da' and, as far as I can tell from a repo-wide grep, has no call sites at all (before or after this PR). Since this PR already did the work to generalize every other call site via getDialectCode, it'd be worth either wiring this one up too or removing it if it's genuinely dead — carrying forward dead single-dialect code in a PR whose whole point is killing single-dialect hardcoding is a little ironic.
  • classifyTone's existence check reads window.reverseToneMappingData.vowel_map purely as a "data loaded" sentinel even though the function no longer uses vowel_map for lookups — this is called out in a comment, which is good, but it's a slightly fragile invariant (if vowel_map is ever dropped from reverse_tone_mapping.json as truly dead data, this guard silently breaks). Consider checking for dialect_reverse_map presence only, since that's what's actually used.
  • The new getDialectCode() consolidation (single lookup for dialect-string → engine code) is a solid fix for the exact kind of drift bug the comment describes (海陸 hardcoded to 'da', missing 饒平) — good catch and good fix.

Performance / Security

  • looksLikePhoneticRegex compiles user search input directly via new RegExp(keyword, 'i') (main.js ~2765). This is client-side only against the user's own locally cached dictionary, so it's not a cross-user attack surface, but a pathological pattern (e.g. catastrophic-backtracking regex) could hang the user's own tab. Not a blocker, but might be worth a length cap or a simple complexity heuristic given this is exposed directly to free-text search input.
  • No other security concerns — no new use of innerHTML with unsanitized external input beyond what already existed, and the new JSON data files are static/bundled, not user-controlled.

Style / conventions

  • Cache-busting query strings in index.html were bumped correctly per AGENTS.md conventions (style.css, main.js, game-ui.js), and data/data_version.json was bumped to force an IndexedDB refresh so the new sandhi_rules.json/reverse_tone_mapping.json get (re)fetched — good, and correctly added to DATA_FILES_TO_CACHE in main.js too.
  • Comments are thorough and explain non-obvious invariants well (e.g. the 【】 bracket-skip being 饒平-only, the single-syllable assumption in the NFD tone-extraction regex). This matches the "preserve/explain intent" convention in AGENTS.md.
  • Minor: this is a user-facing feature (new dialects get sandhi coloring, plus a new phonetic regex search mode) but whatsnew.md wasn't updated, which previous entries in that file suggest is the project's normal practice for shipped features.

Test coverage

  • Good addition overall: 15 fixtures spanning all 5 dialects, including an edge case for the bracket-skip behavior and a "no sandhi applies" case. As noted above, consider adding one more fixture to pin down the 24/24v distinction for 詔安 as a front tone.
  • The new phonetic-regex search mode (looksLikePhoneticRegex) has no test coverage at all (there wasn't an existing test harness for search behavior, so this is consistent with the status quo, but flagging since it's new logic with edge cases — e.g. malformed regex, mixed Hakka-vowel + regex-metacharacter input).

Overall this is a well-structured, well-documented refactor with real test coverage added where there was none — nice work. The window-guard issue is the one thing I'd actually want addressed since it affects the reliability of the new CI job's failure signal; the rest are minor polish items.

@GJRobert
GJRobert merged commit 178762f into main Jul 18, 2026
4 checks passed
@GJRobert
GJRobert deleted the feat/sandhi-expansion branch July 18, 2026 01:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant