简介:这是一套面向 Lojban 语言学习者和开发者的开源工具组合包,汇集了解析器、词典查询界面、IRC 机器人及多种辅助脚本,既适合入门者熟悉 Lojban 语法与词汇,也适合开发者在此基础上进行二次开发。压缩包内含 2000 个文件,以 1613 个 mp3 音频为主,另有 C/JavaScript 源码、JSON 配置、SVG 图标及 HTML 文档,音频可用于发音学习,源码与配置便于理解工具内部结构或自行扩展。资源整体约 44.38MB,体积适中,并附带 Docker 部署方案,可快速搭建本地服务。目前已有 166 人学习下载。通过包内的词典数据、语法解析器及机器人脚本,读者能够获得一套完整的 Lojban 实践环境,结合预览中的句子解析与词典校验工具,可辅助进行语句分析和词汇查询,对深入研究这门人工语言很有帮助。
1. 组合 Lojban 工具:从分词到释义,一条命令跑通
Lojban 这门人工语言最让新手头疼的,不是记规则,而是拿到一个长句子根本不知道该从哪儿断词、哪些是语法标记、哪些是实义词。我前阵子把自己的常用工具整理成了 livla——不是新造轮子,而是把解析器、词典数据和检索界面组合到一条链路上:输入一个 Lojban 句子,先做形态切分和句法解析,再逐词查释义,最后在浏览器里看到带高亮的可读结果。这个过程对正在学 Lojban、或者想研究人工语言处理的人来说,能省掉大量来回切换工具的精力。
2. 解析器选型:为什么非用专门的文法解析器不可
2.1 Lojban 的形态与句法层,决定了通用分词器靠不住
Lojban 最反直觉的一点是:词与词之间不需要空格,常出现一个长词由多个词根复合而成,比如bajra和cakla可以拼出bajracakla。通用分词器按空格切词自然失效,哪怕切开了,也无法判断哪个是形态前缀、哪个是词根。更麻烦的是,Lojban 的句法基于谓词逻辑,一个句子要拆成sumti(参数)和selbri(谓词),这跟自然语言的短语结构树完全不同。
所以 livla 在选型时没有考虑通用 NLP 工具,只盯着实现了完整 Lojban 文法的解析器。这类解析器通常分为两层:第一层做形态分析,识别cmene(专名)、gismu(根词)、lujvo(复合词)等类型;第二层做句法分析,把词序列组装成bridi(判断式)结构。两层都做对,后续查词典才有可靠的lemma(词形原形)可以用。
| 对比项 | 通用分词器 | Lojban 专用解析器 |
|---|---|---|
| 分词依据 | 空白、标点 | 形态规则、词类前缀 |
| 句法输出 | 短语结构树 | sumti/selbri 逻辑结构 |
| 词典匹配 | 按完整词形 | 按 lemma 原形 |
| 歧义处理 | 概率模型 | 确定性文法回溯 |
我的习惯是:不管初始输入多乱,先用解析器得到 token 序列,再拿 token 的 lemma 去查词典,而不是拿原文去匹配。这样能避开很多拼写变体。
2.2 封装解析器:从句子到 JSON 的一层薄壳
livla 里的解析模块没有自己重新实现文法,而是在开源解析库上包了一层 Python 接口。这样做的原因是:解析器通常只提供命令行或 C 接口,直接从前端调用很别扭;包一层后,后续替换底层实现不污染上层代码。
代码:
# livla/parser.py from lojban_parser import Parser, ParseError def analyze(text: str, mode: str = "strict", max_tokens: int = 500) -> dict: """把 Lojban 句子解析成结构化结果。 mode 说明: strict - 严格按照标准文法,大小写、句点都必须正确; relaxed - 容忍常见的省略形式和拼写变体,适合学习场景。 """ p = Parser(mode=mode, max_tokens=max_tokens) try: parsed = p.parse(text) return parsed.to_dict() except ParseError as e: return { "error": str(e), "position": getattr(e, "position", -1) }这段代码的逻辑是:Parser对象支持mode和max_tokens两个关键构造参数;parse返回的ParsedText对象内部已经完成形态和句法分析,to_dict()把它转成 Python 字典,方便后续丢给 JSON 序列化。
参数上,mode直接影响容错率。我做测试时发现,同一个句子mi prami do,strict 模式要求句首字母小写,relaxed 模式能容忍大写开头。max_tokens则用来保护后端:如果用户粘贴一长段文本,解析器可能进入指数级回溯,限制 token 数能提前拒绝超长输入。
2.3 解析结果结构:tokens、syntax、meta 三个字段各有用处
analyze返回的字典里,最核心的是tokens和syntax两个字段。以句子ti broda(这是一个 x1)为例,输出大致是:
{ "text": "ti broda", "tokens": [ {"pos": "sumti", "value": "ti", "lemma": "ti"}, {"pos": "selbri", "value": "broda", "lemma": "broda"} ], "syntax": { "root": "bridi", "children": [ {"node": "sumti", "children": [{"node": "word", "value": "ti"}]}, {"node": "selbri", "children": [{"node": "word", "value": "broda"}]} ] } }tokens是扁平的词法序列,前端做高亮和逐词释义很方便;syntax是嵌套树,叶子节点才是实际词,适合做结构可视化。这里有个常见误用:直接在syntax树里抓所有value字段去查词典,忽略了非叶子节点没有 lemma。我一般只用tokens里的 lemma,这样最稳。
2.4 常见误用:把解析器当分词器
很多初接 livla 的人看到tokens就以为它是一个“智能分词器”,直接拿value做字符串匹配。实际上解析器返回的lemma才是查词键,value可能是复合词的一部分或变体。比如bajracakla,value是完整复合词,但词典里更可能收录bajra和cakla两个根词,此时应该用解析器内部给出的两个 lemma 分别查。
3. 词典数据与搜索接口:让每个词都有可追溯的释义
3.1 词典数据从哪里来,清洗是第一道坎
Lojban 的词典数据通常以 TSV 或 XML 形式散落在社区仓库里,字段格式不统一,有的只有lemma+翻译,有的有词性、例句、来源。直接把原始文件加载到内存会让搜索接口很难做。livla 的处理方式是先清洗成统一 JSON,再建索引。
我写的清洗脚本做了三件事:第一,丢弃以#开头的注释行和空行;第二,把同一 lemma 的不同词性拆成多条记录;第三,过滤掉含空格的多词条目,避免污染前缀索引。
# livla/clean_dict.py import json def clean_tsv(tsv_path: str) -> list: rows = [] with open(tsv_path, encoding="utf-8") as f: for line in f: if line.startswith("#") or not line.strip(): continue parts = line.rstrip("\n").split("\t") if len(parts) < 3: continue rows.append({ "lemma": parts[0], "pos": parts[1], "gloss": parts[2] }) return rows if __name__ == "__main__": data = clean_tsv("raw_dict.tsv") with open("dict.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)这里parts[0]是 Lojban 词形,parts[1]是词性标签,parts[2]是中文或英文释义。清洗后每行都是一条独立记录,后续查询逻辑可以专注在一个统一结构上。
3.2 搜索逻辑:前缀优先、候选限流
学习者在输入框里通常只记得词根的一部分,所以搜索接口必须支持前缀匹配。livla 里我用bisect在排序后的 lemma 列表上做二分查找:先定位第一个不小于前缀的位置,再向右扫描直到不匹配或达到数量上限。
# livla/dict_index.py from bisect import bisect_left class DictIndex: def __init__(self, entries: list): self._entries = entries self._lemmas = sorted({e["lemma"] for e in entries}) def prefix_search(self, prefix: str, limit: int = 10) -> list: if not prefix: return [] i = bisect_left(self._lemmas, prefix) result = [] while i < len(self._lemmas) and self._lemmas[i].startswith(prefix): result.append(self._lemmas[i]) if len(result) >= limit: break i += 1 return resultlimit参数很关键。Lojban 词形较短,输入两个字母可能匹配几十条,不加限制会把前端下拉列表撑爆。我设置默认 10,并且在前端显示“还有 N 条未显示”。
3.3 精确查询与多词性展示:用分组避免选择困难
当用户点击某个候选词,livla 需要返回该词的所有释义。直接返回扁平列表容易让人困惑,同一个词既可能是名词也可能是动词,解释混在一起没法看。我改成按词性分组:
def lookup_grouped(self, lemma: str) -> dict: groups = {} for e in self._entries: if e["lemma"] == lemma: groups.setdefault(e["pos"], []).append(e["gloss"]) return groups这样前端可以用下拉框让用户选择词性,而不是堆一长串释义。比如zgana既是某词根又是某 cmavo,分组后一目了然。
4. HTML 界面:把解析与搜索接到浏览器上
4.1 架构:一个轻量后端加一个单页
livla 的界面没有做成复杂的前端工程,而是一个单页 HTML + 一个 Flask 后端。后端只提供 JSON 接口,前端用原生 JavaScript 拉数据。这样做的优势是:任何人都可以绕过界面,直接curl接口;前端挂了后端仍然可用,方便调试。
4.2 Flask API:把 parser 和 dictionary 串起来
后端接口的核心是把解析结果和词典查询拼在一起。对每个 token,拿到它的 lemma 后调用lookup_grouped,把释义挂在对应的 token 上。
# livla/app.py from flask import Flask, request, jsonify from livla.parser import analyze from livla.dict_index import DictIndex app = Flask(__name__) dict_index = DictIndex.load("dict.json") @app.route("/api/analyze", methods=["POST"]) def analyze_endpoint(): payload = request.get_json(force=True) text = payload.get("text", "").strip() if not text: return jsonify({"error": "empty text"}), 400 mode = payload.get("mode", "strict") result = analyze(text, mode=mode) for token in result.get("tokens", []): lemma = token.get("lemma", token.get("value", "")) token["glosses"] = dict_index.lookup_grouped(lemma) return jsonify(result)注意force=True是 Flask 在接收application/json请求体时的常用兜底参数,确保即使客户端少写了Content-Type也能解析。mode从请求体读取,前端可以自由切换 strict/relaxed。
4.3 前端渲染:悬停高亮和释义列表
页面只需要一个输入框、一个按钮和一个结果容器。JavaScript 拿到 JSON 后,把 token 按顺序渲染成<span>,每个 token 的title属性放释义,悬停即可查看。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> .token { margin: 0 6px; padding: 2px 6px; cursor: pointer; } .token:hover { background: #d0e0ff; } </style> </head> <body> <textarea id="src" rows="3" style="width: 100%;"></textarea> <button id="run">解析</button> <div id="result"></div> <script> document.getElementById('run').onclick = async () => { const src = document.getElementById('src').value; const r = await fetch('/api/analyze', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: src, mode: 'relaxed'}) }); const data = await r.json(); let html = '<p>'; for (const t of data.tokens) { const first = t.glosses && Object.values(t.glosses)[0]; const hint = first ? Array.isArray(first) ? first[0] : first : t.lemma; html += `<span class="token" title="${escapeHtml(hint)}">${escapeHtml(t.value)}</span>`; } html += '</p>'; document.getElementById('result').innerHTML = html; }; function escapeHtml(s) { return s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } </script> </body> </html>这里有一个坑:直接用innerHTML拼接 token 的value时,如果原句是引号内容,可能会破坏页面结构。所以escapeHtml不能省。悬停提示只取第一个词性的第一条释义,想看完整释义以后再扩展成点击弹层。
5. 避坑与常见问题:解析失败、查不到词、界面乱码
5.1 strict 模式下大小写和句点翻车
现象:输入Mi prami do到 strict 模式,解析直接报错;换成mi prami do就正常。原因:Lojban 中大写字母是专名标记,句首大写会被解析器当作 cmene,导致语法不匹配。解决:进入解析器前做预处理,把句首的大写字母转成小写,前提是用户没有真的在写专名。
text = "Mi prami do" if text[0].isupper() and not text.startswith("."): text = text[0].lower() + text[1:]这个方法只处理句子首字符。如果句子内部出现专名,比如la .x.,不该动它。经验是:strict 模式留给已经比较熟练的用户,初学者界面默认用 relaxed。
5.2 词典重复条目导致“查一个词出来一堆没用的”
现象:搜索ti,返回五六个词条,且释义互相矛盾。原因:词典数据源里同样的 lemma 被多个词性记录,而且没有合并。解决:在lookup_grouped中按词性聚合,并在前端明确显示词性标签,而不是把所有释义平铺。
5.3 HTML 页面中文乱码
现象:浏览器打开页面,按钮文字和释义提示全是问号。原因:HTML 没有声明 UTF-8,Flask 返回响应时默认编码可能不是 UTF-8。解决:在<head>里加<meta charset="utf-8">,同时在 Flask 接口返回 JSON 时设置jsonify(..., ensure_ascii=False),否则中文会被转成\uXXXX逃逸序列,浏览器端虽然能解析,但阅读源码和日志很痛苦。
5.4 长句子请求超时,浏览器一直转圈
现象:往输入框粘贴一段 300 词的长文本,点击解析后要等十几秒。原因:Lojban 解析器在复杂长句上的回溯呈指数增长,后端单线程被拖死。解决:前端限制输入长度不超过 200 字符;后端用functools.lru_cache缓存同文本同模式的解析结果。
from functools import lru_cache @lru_cache(maxsize=256) def cached_analyze(text: str, mode: str) -> dict: return analyze(text, mode)注意lru_cache要求参数可哈希,text和mode都是字符串,没问题。缓存能在重复测试时省下大量时间,但如果你在调试解析规则,记得手动清缓存或加调试开关。
6. 进阶技巧:解析树可视化与结果缓存
6.1 把 syntax 树渲染成可折叠的 HTML 列表
tokens适合逐词查词,但想看句子结构还得靠syntax。我写了个递归渲染函数,输出嵌套的<ul>/<li>,并在父节点上加点击事件切换子列表显示。
def render_tree(node: dict) -> str: if "children" not in node or not node["children"]: return f"<li class='leaf'>{node.get('value')}</li>" children_html = "".join(render_tree(child) for child in node["children"]) return f"<li class='branch'>{node.get('node')}<ul style='display:block'>{children_html}</ul></li>"渲染时把style="display:block"写成内联,方便初始展开;点击时再把block改成none就能折叠。这样长句子的逻辑结构可以一层层收起来,比看一个大 JSON 直观得多。
需要注意的是:递归时要注意children为空的情况,否则会产生空<ul>,影响布局。我在代码里做了叶子节点判断。
6.2 用缓存避免重复分析与释义查询
除了隐性递归,livla 还有一个隐患:每次点击解析都会查词典,哪怕同一个词反复出现。我的习惯是给lookup_grouped也加一层缓存,按 lemma 缓存分组结果。
from functools import lru_cache @lru_cache(maxsize=2048) def lookup_cached(lemma: str) -> dict: return dict_index.lookup_grouped(lemma)因为词典数据在运行中不变,缓存完全安全。这样用户反复解析相似句子时,后端压力会小很多。
从那以后,我每次往 livla 里加新功能,都会先跑一遍预处理、解析、查词、渲染的完整链路,确认哪一个环节会最先出问题。尤其是 strict 模式下的大小写预处理,已经成了肌肉记忆。希望帮到你。
本文还有配套的精品资源,点击获取