纯静态HTML实现Lojban无空格切词与词典检索

发布时间:2026/10/27 17:59:16

纯静态HTML实现Lojban无空格切词与词典检索 简介面向 Lojban 语言学习者、构造语言研究者以及词典与解析工具开发者的组合资源汇集解析器、词典软件、搜索界面和配套数据并以 Docker 或 podman 容器化方式组织方便在统一环境中体验与二次开发。压缩包共 2000 个文件约 44.38MB以 mp3 音频数量最多另含 C 源码、JavaScript、JSON 配置、HTML 页面、SVG/PNG 图形、ogg 音频及 Perl 脚本等覆盖词条数据、解析实现、前端展示和机器人配置等多类内容目录中保留多种实现与配置样例便于对照不同模块的职责。已有 169 人学习下载适合希望了解 Lojban 工具链全貌的读者。对语言爱好者而言词典与发音素材可辅助词义和读音学习对开发者来说解析器源码、检索界面与容器配置提供了可参考的实现路径也便于在此基础上扩展查询、机器人或前端功能具备较好的研究与复用价值。1. 一句话里没有空格怎么查拿到一句没写空格的 Lojban 文本我第一反应是拆词结果卡在「该从哪儿下刀」上klama、laklama这种连续字母流肉眼看不出边界。livla 就是冲着这个场景做的——它把 parser、词典检索和 search-interface 三件事塞进一个纯静态 HTML 页面输入原文中间出切好的词右侧出释义切错的地方标红。它不做成套的语法分析也不求把整句译出来只解决「读到一半卡住、想立刻查明白」这一段。它适合三类人读文本时词形记不全、需要边读边查的想给自己那套小工具配一个现成前端外壳的以及想看看 dictionary-software 在纯浏览器里怎么落地的。整个项目没有后端、没有数据库fetch三个 JSON 就能跑改起来成本很低。2. 整体架构parser、词典、搜索界面在同一个 HTML 里怎么分工2.1 三层职责解析是纯函数词典是只读数据渲染只碰 DOM我把这个项目切成三层切分的依据不是文件多少而是依赖方向。最底下的解析层lexer.jsparser.js是纯函数输入字符串和词典 Map输出一个普通数组不碰 DOM、不读全局状态。中间的数据层dict.js只负责把 JSON 装进Map装完就不再变。最上面的视图层render.jsmain.js拿解析结果去拼 HTML只有它碰 DOM。这么切有两个直接好处。第一解析层能单独跑单元测试——node --test里直接import { tokenize }就行不用起浏览器。第二词典能整体替换想加一门语言的释义只要新 JSON 的 key 结构对得上lexer和render一行都不用改。反过来如果一开始就把「切词 查词 innerHTML」写在一个input事件回调里等你发现某条词形规则写错时得先把渲染逻辑扒开才能调试这是血泪经验。三层之间没有框架、没有响应式状态。我一般是这个顺序input事件 →normalize归一化 →tokenize切词 →parse补位置和配对信息 →render拼 HTML。每一步的产物都是可打印的普通对象出问题直接console.log中间结果比在 DevTools 里打断点省事。2.2 目录结构与构建产物项目是纯静态的index.html直接放在根目录靠一个静态服务器托管。目录长这样livla/ ├── index.html # 唯一入口输入框 结果区 ├── assets/ │ ├── css/style.css # 词类配色、错误态样式 │ └── js/ │ ├── main.js # 事件绑定串联三层 │ ├── lexer.js # 归一化 切词 词形判定 │ ├── parser.js # 括号/引号配对产出 span 列表 │ ├── dict.js # 词典装载、正查、反查 │ └── render.js # 转义 高亮拼接 ├── data/ │ ├── cmavo.json # 结构词 │ ├── gismu.json # 词根 │ └── index.json # 预构建的英文倒排索引 └── tools/ └── build-index.mjs # Node 脚本生成 index.jsondata/里的三个文件是运行时产物不是手写源码。原始词表通常从社区词典导出字段一大堆直接丢给前端加载会拖慢首屏。tools/build-index.mjs的职责就是把原始词表压成前端真正需要的最小结构{ 词形: { type, gloss, rafsi } }顺便把英文释义切成倒排索引。这一步放在构建期做浏览器端就只剩一次fetch。index.html里所有script都要带typemodule否则import直接报语法错误而且这个错误在控制台里不显眼很容易被当成「文件没加载」。2.3 一次查询的完整链路main.js是唯一知道所有模块存在的地方它短到可以整段贴出来// main.js —— 只做三件事装词典、绑事件、串流水线 import { normalize, tokenize } from ./lexer.js; import { parse } from ./parser.js; import { loadDict } from ./dict.js; import { render } from ./render.js; const $src document.querySelector(#src); const $out document.querySelector(#out); const $hits document.querySelector(#hits); // 顶层 await模块脚本里合法。词典没装完就切词等于让 parser 空手跑 const dict await loadDict(./data); $src.addEventListener(input, () { const source normalize($src.value); const tokens tokenize(source, dict.byWord); const spans parse(tokens, source); render(source, spans, $out); // 原文着色 renderHits(tokens, dict, $hits); // 右侧释义列表 }); function renderHits(tokens, dict, el) { const seen new Set(); const rows []; for (const tk of tokens) { if (seen.has(tk.text)) continue; // 同一句里重复的词只列一次 seen.add(tk.text); const entry dict.byWord.get(tk.text); rows.push(lib${tk.text}/b i${tk.type}/i ${entry?.gloss ?? 未收录}/li); } el.innerHTML rows.join(); }这里有两个参数值得说清楚。dict.byWord是个Map切词时当查询表用tokens里的每一项都带text和typetype是cmavo、brivla、cmevla、error之一下游渲染靠它决定配色。renderHits里那个seen去重是有意为之——一段话里le可能出现七八次全列出来右侧就没法看了。链路里最容易被忽略的是normalize和tokenize的分工归一化只管字符替换大小写、h和的等价、标点单独成词切词只管划分边界和判定词类。两件事混在一个函数里等你发现「标点处理错了」时得连着切词逻辑一起改非常难受。3. 解析器拆解从连续字母流切出 cmavo、gismu 和 cmevla3.1 为什么不能只靠空格切词Lojban 的构词设计本身就保证了词是自定界的一个词的形态能告诉你它到哪儿结束。所以书写时经常省略空格.i开头的句子、cmevla 两侧的句点、y作为音节分隔符都是这条规则的产物。这就带来一个麻烦split( )在这种文本上完全失效。一整串miklamalezarcicuicai拿过来你没有现成的边界可用只能靠形态规则回溯。我在项目早期图省事用了纯正则结果coi ro do能过、.lojban.能过一到la .lojban. cu xamgu .i kui mi na djuno就原形毕露——cu被当成 brivla 的一部分吃掉了整句的词类全错。正确的做法是词典优先 形态兜底能整词命中就采信词典命中不了再按形态规则猜一个类别。这个顺序不能反因为形态规则只能给出大类给不出具体词条而词典能给出精确的词类和释义。反过来如果先按形态切、再查词典遇到 fuivla 这类不规则借用词就彻底没辙。这里还有个细节容易被忽略h和在 Lojban 里是同一个音位的两种写法。真实文本里两种写法都有人用甚至同一篇文章里混用。归一化阶段必须统一否则mie和mihe在Map里是两个完全不同的 key查询永远命中不了。我一开始就栽在这儿写了一个下午的「元音开头的 cmavo 查不到」的 bug最后发现是数据层的问题。另一个容易漏掉的是句点。.在 Lojban 里既是 cmevla 的定界符也是句号还可能是.i这种句首分隔符的一部分。我的处理方式是先把.从词首词尾剥掉再查剥完如果是空的就跳过。这样.lojban.、lojban、.lojban三种写法会归到同一个 key用户不管怎么写都不影响查询结果。3.2 切词器实现lexer.js的前半段是字符层面的判断都是小函数// lexer.js —— 字符分类与归一化 const VOWELS new Set([a, e, i, o, u, y]); // y 在 lojban 里是元音 const CONSONANTS new Set([...bcdfgjklmnprstvxz]); // 不含 和 h它们单独归一 export function normalize(text) { return text .toLowerCase() .replace(/h/g, ) // h 与 等价统一成 .replace(/([.,!?;:])/g, $1 ) // 标点单独成 token别粘在词上 .replace(/\s/g, ) .trim(); } // gismu / lujvo / fuivla 的硬性特征前几个字符里必然出现辅音对 function hasConsonantPair(s) { const head s.slice(0, 6); for (let i 0; i head.length - 1; i) { if (CONSONANTS.has(head[i]) CONSONANTS.has(head[i 1])) return true; } return false; }hasConsonantPair里的slice(0, 6)不是随手写的。gismu 的两种标准形态是 CVCCV 和 CCVCV长度都是 5辅音对一定落在前 4 个字符内。取 6 是给 lujvo 的前缀组合留一点余量同时避免把整词扫一遍——长文本里这个函数会被调用上千次多扫几个字符就是几百次无谓比较。normalize里那句标点处理用了捕获组包一层目的是让.两边都有空格这样后面split( )才切得干净。我试过直接replace(/\./g, . )结果...这种连续句点会被拆成一堆空串filter(Boolean)之后还得额外去重反而更绕。3.3 词形判定与词典兜底的优先级真正的切词函数按四级优先级走顺序就是注释里标的 1 到 4// lexer.js —— 按优先级判定越靠前越可信 export function tokenize(text, dict) { const out []; for (const raw of normalize(text).split( )) { if (!raw) continue; const word raw.replace(/^\.|\.$/g, ); // 剥掉 cmevla 的定界句点 if (!word || !/^[a-z]$/.test(word)) continue; // 1) 词典整词命中直接采信这是唯一能给出精确词类的路径 if (dict.has(word)) { out.push({ text: word, type: dict.get(word).type }); continue; } // 2) 以辅音结尾 → cmevla名字词形态上不会错 if (CONSONANTS.has(word.at(-1))) { out.push({ text: word, type: cmevla }); continue; } // 3) 前六个字符含辅音对 → brivlagismu/lujvo/fuivla 都归这里 if (hasConsonantPair(word)) { out.push({ text: word, type: brivla }); continue; } // 4) 兜底剩下的按结构词处理 out.push({ text: word, type: cmavo }); } return out; }第 2 条规则辅音结尾是 cmevla是我最信任的一条因为它在 Lojban 的构词规则里没有例外——brivla 和 cmavo 都以元音结尾只有名字词以辅音结尾。这条规则的代价是lojban这种既可能是 cmevla 又可能被当成普通字母串的写法会被无条件判成 cmevla。我接受这个偏差因为真实文本里它确实基本都是名字。第 3 条规则是近似判断。严格来说cmavo 内部不允许出现辅音对所以这条规则在单句层面是可靠的但y作为音节分隔符出现的场合比如ky、lyn会让判定抖动。我的处理是不做特殊分支接受轻微误判——反正渲染时 brivla 和 cmavo 只是颜色不同不影响查词。对于完全没写空格的连续流还需要一个回溯切分器// lexer.js —— 无空格连续流的贪心切分取最长命中失败退一格 export function segment(run, dict, maxLen 12) { const spans []; let i 0; while (i run.length) { let hit null; const ceiling Math.min(maxLen, run.length - i); for (let len ceiling; len 1; len--) { const cand run.slice(i, i len); if (dict.has(cand)) { hit cand; break; } } if (!hit) hit run[i]; // 兜底单字符前进保证循环一定收敛 spans.push({ text: hit, type: dict.get(hit)?.type ?? unknown, start: i, }); i hit.length; } return spans; }maxLen 12是我按实际词长统计定的绝大多数 cmavo 不超过 6 个字符lujvo 偶尔到 10 以上12 已经能覆盖到绝大多数情况。这个值调大会让每个位置的回溯次数线性上升一段 200 字符的文本就能明显卡顿调小则会漏掉长 lujvo。你可以按自己的语料分布调但别指望一套参数通吃。segment的贪心策略在 Lojban 上并不完备——存在「短词优先反而正确」的场合。我的取舍是先用segment出一版结果渲染时把unknown类型的 token 标成灰色让用户自己看出来哪一段没切对而不是硬猜。3.4 轻量语法层括号、引号与位置回填parser.js不做句法树只做三件事把 token 在原始字符串里的位置填回去、做括号配对、给未闭合的括号打标记。原因很实际——Lojban 的完整语法是一套 PEG前端全量实现会让包体积翻几倍而这个项目的定位只是辅助查词。// parser.js —— 补位置 括号配对产出给渲染层用的 span 数组 const PAIRS { (: ), [: ], {: } }; const CLOSERS new Set(Object.values(PAIRS)); export function parse(tokens, source) { const spans []; const stack []; let cursor 0; for (const tk of tokens) { // indexOf 从 cursor 起找避免同一个词多次出现时位置错乱 const start source.indexOf(tk.text, cursor); if (start 0) continue; // 归一化后对不上直接跳过 const end start tk.text.length; cursor end; if (PAIRS[tk.text]) { stack.push({ ch: tk.text, at: spans.length }); } else if (CLOSERS.has(tk.text)) { const top stack.pop(); if (!top || PAIRS[top.ch] ! tk.text) { spans.push({ kind: error, start, end, text: tk.text, msg: 括号不匹配 }); continue; } } spans.push({ kind: tk.type, start, end, text: tk.text }); } // 结算时栈里还剩的全是没闭合的左括号 for (const left of stack) spans[left.at].unclosed true; return spans; }位置回填那段用source.indexOf(tk.text, cursor)而不是从 0 开始找是关键。同一个词在一句里出现三次从 0 开始找会让三个 token 的位置全落在第一处渲染出来高亮就会重叠。这个 bug 我在测试mi klama .i mi klama的时候才撞上表现是第二句整段没颜色。unclosed标记是我加的一个小功能左括号没闭合时不报错只在渲染时给这个 token 加一条下划线。真实写作里括号未闭合太常见了直接抛异常会让整个输入框罢工体验很差。4. 词典与搜索dictionary-software 的数据形态和 search-interface 的实现4.1 词典数据用 JSON 还是预渲染 HTML最早我把词条直接写进index.html里几百条还行上千条之后文件体积直接上到几兆而且每加一个词就得改 HTML。后来改成运行时fetchJSON体积问题解决了但首屏又慢——每次输入都重新解析几兆 JSON明显卡。现在的方案是两段式构建期用tools/build-index.mjs把原始词表压成前端最小结构运行期一次性fetch三个文件后常驻内存。三个文件分别是cmavo.json、gismu.json和index.json前两个是词条本体最后一个是英文倒排索引。Promise.all并发拉总耗时约等于最慢那一个。为什么不干脆用一个dictionary.json因为正查和反查的更新频率不一样。词条本体基本是稳定的倒排索引则要看释义文本是否需要重新切词——如果只是想改某个词的释义改完重新跑一遍索引脚本就行词条文件的 diff 很小方便人工核对。合成一个文件之后每次改一个词都会产生几千行 diff没法看。4.2 正查按词形检索dict.js的装载部分很直接重点是byWord这个Map——它同时承担了切词时的查询表和正查时的索引// dict.js —— 三个文件并发装载合并成一张查询表 export async function loadDict(base ./data) { const [cmavo, gismu, index] await Promise.all([ fetch(${base}/cmavo.json).then(r r.json()), fetch(${base}/gismu.json).then(r r.json()), fetch(${base}/index.json).then(r r.json()), ]); const byWord new Map(); // cmavo 先放gismu 后放同名词形以词根为准 for (const [w, e] of Object.entries({ ...cmavo, ...gismu })) { byWord.set(w, { type: e.type ?? brivla, gloss: e.gloss ?? , rafsi: e.rafsi }); } return { byWord, reverse: new Map(Object.entries(index)) }; }合并顺序是有意为之{ ...cmavo, ...gismu }让 gismu 覆盖同名 cmavo。原因是历史上出现过词形冲突而词根的释义更完整覆盖掉损失更小。正查函数三级降级精确命中优先其次前缀匹配最后不做模糊匹配因为 Lojban 词形太规整编辑距离匹配出来的结果噪音极大。// dict.js —— 正查精确 前缀上限 20 条防止长前缀拖垮渲染 export function searchWord(dict, q, limit 20) { const key q.toLowerCase().replace(/h/g, ).trim(); if (!key) return []; if (dict.byWord.has(key)) { return [{ word: key, exact: true, ...dict.byWord.get(key) }]; } const out []; for (const [word, entry] of dict.byWord) { if (word.startsWith(key)) { out.push({ word, exact: false, ...entry }); if (out.length limit) break; } } return out; // 可能为空交给视图层显示「无匹配」 }limit 20不是随便定的。用户输入一个abyWord里以a开头的词可能有几百个全量渲染成列表会让右侧面板卡住半秒。截断之后再在列表底部显示「还有 N 条」这个 N 需要额外跑一次完整计数——我的做法是干脆不显示让用户多打几个字符。前缀匹配用的是Map的线性遍历词表规模在几千条时完全够用。如果你想扩到几万条就得换成按首字母分桶的Mapstring, string[]或者干脆把倒排索引做成 trie这属于第 6 章的话题。4.3 反查从英文释义找词反查是最实用的功能也是我花时间最多的地方。用户想表达「走」这个概念但不知道对应哪个词根就得从英文释义反推。难点在于释义文本是自然语言直接includes匹配完全不靠谱goes和go对不上walking和walk也对不上。我的处理是在构建期做一次简单的词干化// tools/build-index.mjs —— 把释义切成倒排索引键是词干 import { readFile, writeFile } from node:fs/promises; const STOP new Set([x1, x2, x3, x4, x5, the, a, an, to, of, or, and]); const stem w w .replace(/ies$/, y) .replace(/(ches|shes|xes|ses)$/, m m.slice(0, -2)) .replace(/(ing|ed)$/, ) .replace(/s$/, ); const gismu JSON.parse(await readFile(data/gismu.json, utf8)); const index {}; let entryCount 0; for (const [word, entry] of Object.entries(gismu)) { entryCount; for (const raw of (entry.gloss ?? ).toLowerCase().split(/[^a-z]/)) { if (raw.length 2 || STOP.has(raw)) continue; const key stem(raw); const bucket (index[key] ?? []); if (!bucket.includes(word)) bucket.push(word); // 同一词根重复出现只记一次 } } await writeFile(data/index.json, JSON.stringify(index)); console.log(词根 ${entryCount} 条索引键 ${Object.keys(index).length} 个);stem里的顺序不能乱。先处理ies再处理s否则stories会先被砍成storie再砍成storiches/shes/xes/ses那几个必须排在s前面因为watches直接去s会得到watche。这类规则永远覆盖不全我在脚本最后打了一行统计跑完看一眼索引键数量如果比词根数量还少说明词干化过头了得回去改正则。反查的查询侧做的是多词 AND// dict.js —— 反查多个英文词取交集go to 会同时匹配到包含 go 和 to 的词根 export function searchGloss(dict, q, limit 20) { const terms q.toLowerCase().split(/\s/).filter(Boolean).map(stemLite); if (!terms.length) return []; const sets terms.map(t new Set(dict.reverse.get(t) ?? [])); const [first, ...rest] sets; const ids [...first].filter(w rest.every(s s.has(w))); return ids.slice(0, limit).map(w ({ word: w, ...dict.byWord.get(w) })); } // 查询侧只做最轻的处理复杂规则全在构建期跑过了 function stemLite(w) { return w.replace(/ies$/, y).replace(/(ing|ed|s)$/, ); }构建期和查询期的词干化必须用同一套规则否则索引里的键和查询时算出来的键永远对不上。我把stem的主体逻辑复制了一份到查询侧只砍掉最耗时的几条分支。这是典型的「一致性 vs 性能」取舍规则完全一致最安全但前端每次按键都跑一遍全量正则不划算。4.4 渲染与高亮把 span 拼回 HTML渲染层只有一条铁律先转义再插标记顺序不能反。用replace做高亮是最容易出事的写法——如果先插mark再整体转义你自己插的标签会被转义掉如果先转义再对整串做replace用户输入的script会被当成标签解析出去。正确做法是单次拼接从头到尾走一遍// render.js —— 逐段拼接原文片段转义、token 包 span、两者交替 const ESC { : amp;, : lt;, : gt;, : quot;, : #39; }; const escapeHtml s s.replace(/[]/g, c ESC[c]); export function render(source, spans, el) { let html ; let cursor 0; for (const sp of spans) { // 两个 token 之间的空白和标点原样转义输出 html escapeHtml(source.slice(cursor, sp.start)); const cls sp.kind error ? tok tok-error : tok tok-${sp.kind}${sp.unclosed ? tok-unclosed : }; html span class${cls}># 三个里挑一个都行 python3 -m http.server 8000 npx serve . php -S 127.0.0.1:8000 # 第一次跑之前记得先构建索引否则 data/index.json 不存在 node tools/build-index.mjs启动后打开http://localhost:8000按三步验证第一步输入mi klama le zarci。预期结果是mi标成 cmavo、klama和zarci标成 brivla、le标成 cmavo右侧列出四个词条释义。如果全部标成灰色或者全部标成同一种颜色说明词典没加载完去看 Network 面板里三个 JSON 是不是都 200。第二步输入coi ro do。这句里coi是问候、ro是「全部」、do是「你」三个都应该是 cmavo。如果coi被标成 brivla说明cmavo.json里缺这一条走了形态兜底。第三步输入la .lojban. cu xamgu。lojban两侧的句点应该被剥掉lojban标成 cmevla因为它以辅音n结尾cu和xamgu正常显示。如果句点还留在词上检查tokenize里那句replace(/^\.|\.$/g, )有没有被误删。第四步在搜索框里输walk应该能反查出对应词根输walk to结果集会变小。如果两次结果一样说明多词 AND 那段没生效。5.2 排查清单现象 → 原因 → 解决下面五条是我自己撞过的按出现频率排序。现象一整句全标成同一种颜色右侧释义全是「未收录」。原因main.js里loadDict是异步的如果写在DOMContentLoaded回调里但又没awaitinput事件可能在词典装好之前就触发了tokenize拿到一个空Map所有词都走形态兜底。 解决把loadDict放在模块顶层await模块脚本支持或者用Promise门闩——词典没回来之前禁用输入框$src.disabled true装完再放开。我选前者代码更短。现象二mie能查到mihe查不到。原因h和的等价只在normalize里做了但dict.js装载时没做同样的归一化词典里的 key 是版本用户输入h版本Map里对不上。 解决归一化必须是双向的——查询侧归一化、词典装载侧也要归一化。我在loadDict里加了一行w.toLowerCase().replace(/h/g, )问题消失。这条教训是只要涉及字符等价两头都得处理只改一头等于没改。现象三反查go搜不到词根但正查明明能查到那个词。原因索引构建时stem(goes)得到goees$规则没覆盖到索引里存的键是goe查询时go算出来的还是go两边对不上。 解决把词干化规则收敛成一组构建期和查询期用同一份代码。我的做法是把stem抽到一个共享的.mjs里build-index.mjs和dict.js都import它虽然打包时会重复一份逻辑但一致性有保障。宁可多几 KB也不要两个地方各写一套。现象四输入带尖括号的文本页面直接崩掉或者弹窗。原因渲染时先做了字符串replace高亮再赋值innerHTML用户输入的img onerror...被当成真实标签解析执行了。 解决改成 4.4 里那种逐段拼接的写法每个要进 HTML 的字符串都过一遍escapeHtml。这条没有折中方案任何「先拼标签再转义」的做法都是错的。现象五粘贴一段 500 字的长文本输入框卡顿明显。原因input事件是每次按键都触发长文本下tokenize加parse加重新渲染全跑一遍一秒钟能跑几十次。 解决加一个简单的防抖setTimeout200 毫秒input时清掉上一个定时器。另外把renderHits里对byWord的重复查询结果缓存到一个局部Map里同一段文本里重复词很多能省掉一多半查询。segment那个回溯函数是长文本卡顿的主要来源。如果只处理带空格的文本可以在tokenize里判断一下split( )之后每段长度超过 20 个字符才走segment其余直接按整词处理性能能提升一个数量级。6. 二次开发换词典、加语言、扩规则我在这个项目上做过三次改造都集中在三处改之前先看清依赖方向就不会乱。加一门语言的释义。这是成本最低的扩展。新建data/zh.jsonkey 和gismu.json完全一致value 里多一个glossZh字段loadDict的Promise.all数组里加一项renderHits里把entry.gloss换成entry.glossZh ?? entry.gloss。整个过程不用碰lexer.js和parser.js因为解析层只认词形和词类不认释义语言。加大词表。当词表从几千条涨到几万条searchWord里的线性遍历会成为瓶颈。改法是按首字母分桶装载时建一个Mapstring, string[]a到z各一个数组查询时只遍历对应桶。再进一步可以上前缀树但对 Lojban 这种词形规整的语言首字母分桶的收益已经足够——大部分词的第一个字母就能把候选集砍到几十分之一。扩词形规则。想支持带y的音节分隔写法比如ky、lyn得改hasConsonantPairy不参与辅音对判定同时要在tokenize里加一条「只含辅音 y 的短串按 cmavo 处理」。改完必须回归测试那几条老用例特别是la .lojban. cu xamgu——y规则最容易误伤 cmevla 的边界判定。验证方式我固定成两步。第一步跑一组纯函数用例直接node --test// test/lexer.test.mjs —— 只测纯函数不起浏览器 import { test } from node:test; import assert from node:assert/strict; import { normalize, tokenize } from ../assets/js/lexer.js; const dict new Map([ [mi, { type: cmavo }], [klama, { type: brivla }], [le, { type: cmavo }], ]); test(h 与 \ 等价, () { assert.equal(normalize(mihe), mie); }); test(辅音结尾判为 cmevla, () { const [tk] tokenize(.lojban., dict); assert.equal(tk.type, cmevla); }); test(词典命中优先于形态兜底, () { const [, tk] tokenize(mi klama, dict); assert.equal(tk.type, brivla); });第二步改完后在浏览器里把 5.1 那四步验证重跑一遍特别关注高亮颜色和右侧释义是否一致。这两步听起来啰嗦但词形规则的改动影响面很大纯靠肉眼看颜色很容易漏。最后一次踩坑是因为我图快直接在生产数据上手改了一条词形规则本地测试全绿上线之后发现有一整类 lujvo 全被切错原因是我本地测试用例里压根没覆盖到那类形态。从那以后我每次动lexer.js之前都强制先把当前所有通过的输入输出存成快照改完逐条比对差异只要有一条变了就得说清楚为什么变。希望这个习惯能帮你少返工几次。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/27 17:59:16

腾讯开源Octop与WorkBuddy:AI Agent开发框架与办公助手的协同布局

最近在好几个技术社群里反复看到一个挺有意思的话题:腾讯都已经有 WorkBuddy 了,怎么转头又把 Octop 开源了?乍一看确实有点像“自己人打自己人”,一个做 AI 工作台,一个做 AI 开发框架,名字听起来都跟“智…

2026/10/27 17:54:15

跌倒检测数据集与模型优化实战

1. 项目背景与核心价值在安防监控和健康照护领域,人员行为识别技术正经历从"看得见"到"看得懂"的进化。这个数据集针对跌倒检测这一具体场景,解决了传统监控系统无法自动识别危险行为的痛点。我曾参与过多个养老机构的智能监控项目&…

2026/10/27 17:54:15

基于MATLAB指纹识别系统设计:图像处理到GUI开发全流程

简介:这份基于MATLAB的指纹识别系统毕业设计源码,面向计算机与电子信息类专业学生,适合课程设计或毕业设计阶段的算法学习与项目复现。压缩包共4个文件,包含两个MATLAB脚本(分别承担预处理与特征提取、指纹匹配&#x…

2026/10/27 18:54:28

ROM搜索算法对多个DS18B20进行地址读取

ROM搜索算法对多个DS18B20进行地址读取 ROM搜索算法是一个用于解决1-wire总线上多个器件地址寻址的算法;实际应用,假设在一条总线上挂载了多个 1-Wire器件、但主机不知道它们地址无法进行通信;此时可以通过ROM搜索算法枚举出每一个器件的64位…

2026/10/27 18:54:28

三强版 Codex 整合包,操作全程可追溯,开发办公都能用

💡前言 不少朋友在 Windows 上使用桌面 AI 智能体时,常会遇到几类困扰:任务执行过程不透明、修改记录难以追溯、缺少定时调度能力,插件扩展也相对有限。如果你已经试过同类工具,正在寻找一套更稳定、全程可审查、功能…

2026/10/27 18:54:28

这款 Codex 本地 AI,支持飞书联动,重复性工作自动搞定

💡前言 不少朋友在 Windows 上使用桌面 AI 智能体时,常会遇到几类困扰:任务执行过程不透明、修改记录难以追溯、缺少定时调度能力,插件扩展也相对有限。如果你已经试过同类工具,正在寻找一套更稳定、全程可审查、功能…

2026/10/27 18:54:28

cuDNN 9.1 Windows 安装指南:cudnn64_9.dll 报错排查与配置

简介:这是一份面向Windows x86-64平台、适配CUDA Toolkit 12的NVIDIA cuDNN 9.1.0.70官方库压缩包,适合需要利用GPU加速深度神经网络训练与推理的开发者、算法工程师及研究人员。包体共32个文件,包含16个.lib库文件、8个.dll动态链接库、7个.…

2026/10/27 18:54:28

第十六节 IP协议格式

一、IP协议1.1、协议格式:4位版本:描述当前协议版本ipv4,ipv6……4位头长:描述IP报的头部长度,最小20字节,最大60字节8位服务类型;1位保留0;3位优先权;4位服务类型:最小成本&#xf…

2026/10/27 18:49:27

刷机维修进阶教程-----读懂 PDA 设备:序列号、IMEI 这些参数到底存在哪里?不同芯片之间的寻址方式【一】

在接待设备定制化过程中,常会遇到客户要求修改设备串码、序列号等参数的需求,其中以PDA设备最为常见。与手机类设备不同,PDA设备的参数修改方式具有特殊性,因此需要我们掌握其基础常识及具体的修改机制。本期系列博文将分三篇深入解析此类参数的查找与修改流程,帮助您高效…

2026/10/27 14:24:03

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/27 9:34:54

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/27 0:59:21

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价

“模式选错”的典型代价 摘要 在软件工程中,设计模式、架构模式、并发模式乃至团队协作模式的选择,往往在项目早期就决定了后续的开发效率和维护成本。本文通过多个真实场景,剖析“模式选错”带来的典型代价——包括返工成本、技术债务累积、…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式20_新手推荐路径

新手推荐路径 摘要 在 AI 辅助开发工具快速普及的今天,新手面对最多的困惑往往不是"工具够不够强",而是"我该从哪一步开始用"。本文提出一条经过实践验证的三阶段学习路径:先从 Ask(问答式交互)起…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑