cmd_read.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. """检索与阅读的子命令:forms / word / search / dist / get。
  2. 全部只读,不需要凭据。
  3. """
  4. import html as html_mod
  5. import json
  6. import re
  7. import sys
  8. from client import make_client, note
  9. from coords import fmt_coord, fmt_path, parse_coord, parse_coords, text_layer
  10. from errors import ApiError, WpError, explain_api_error
  11. # 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
  12. # 是同一个调用换 channel。
  13. PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
  14. # 靠 channel 名字判断机器译文很脆弱:库里既有名字含 "AI" 的,也有直接用模型名命名的
  15. # (deepseek / qwen-max / grok-简体中文 / gemini / 豆包 / ChatGPT),后者不含 "ai"。
  16. # 这个清单只用来「提醒去核实」,不作为判定依据——权威判定看 get 返回的作者是不是模型。
  17. MACHINE_HINTS = ('ai', 'gpt', 'chatgpt', 'claude', 'deepseek', 'gemini', 'qwen', 'grok',
  18. 'llama', 'mistral', 'kimi', 'norbu', '豆包', '文心', 'ernie', '通义')
  19. # 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
  20. READ_TIMEOUT = 60
  21. def strip_markup(raw, hl='【】', bold='**'):
  22. """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。
  23. 命中词用 <span class='hl'> 包,黑体是原文的 <span class="bld">——后者是注释书
  24. 标出词条的地方,对判断「这段是不是定义」很关键,不能丢。
  25. """
  26. if not raw:
  27. return ''
  28. text = raw
  29. text = re.sub(r"<span class='hl'>(.*?)</span>", hl[0] + r'\1' + hl[1], text, flags=re.S)
  30. text = re.sub(r'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
  31. text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
  32. text = re.sub(r'<[^>]+>', '', text)
  33. text = html_mod.unescape(text)
  34. return re.sub(r'\s+', ' ', text).strip()
  35. def snippet(text, width, around=None):
  36. """截断文本;给了 around 就尽量把它所在的位置露出来。"""
  37. if len(text) <= width:
  38. return text
  39. if around:
  40. pos = text.find(around)
  41. if pos > width // 2:
  42. start = pos - width // 3
  43. return '…' + text[start:start + width] + '…'
  44. return text[:width] + '…'
  45. def emit(args, payload, render):
  46. if getattr(args, 'json', False):
  47. print(json.dumps(payload, ensure_ascii=False, indent=2))
  48. else:
  49. render()
  50. # ---------------------------------------------------------------------------
  51. # forms —— 词形展开,一切检索的前置
  52. # ---------------------------------------------------------------------------
  53. def fetch_forms(client, word):
  54. try:
  55. data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT)
  56. except ApiError as exc:
  57. raise explain_api_error(exc, f'展开词形 {word}')
  58. return (data or {}).get('rows') or []
  59. def cmd_forms(args):
  60. client = make_client(args)
  61. rows = fetch_forms(client, args.word)
  62. if not rows:
  63. raise WpError(
  64. f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),'
  65. '或换一个可能的词根再试。'
  66. )
  67. def render():
  68. for idx, row in enumerate(rows[: args.limit], 1):
  69. forms = row.get('case') or []
  70. total = sum(int(f.get('count') or 0) for f in forms)
  71. bold = sum(int(f.get('bold') or 0) for f in forms)
  72. mark = ' ← 可能性最高' if idx == 1 else ''
  73. print(f'[{idx}] {row.get("word")} {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}')
  74. for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)):
  75. print(f' {f.get("word"):<20} {f.get("count"):>5} 次 黑体 {f.get("bold")}')
  76. print()
  77. print('检索用(第一候选的全部词形):')
  78. print(' ' + forms_arg(rows[0]))
  79. if len(rows) > 1:
  80. print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。')
  81. emit(args, rows, render)
  82. return 0
  83. def forms_arg(row):
  84. """把一个候选的全部词形拼成 search 要的逗号串。"""
  85. return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word'))
  86. # ---------------------------------------------------------------------------
  87. # word —— 词典释义与形态分析,用来确认选对了词根
  88. # ---------------------------------------------------------------------------
  89. def cmd_word(args):
  90. client = make_client(args)
  91. try:
  92. data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang},
  93. timeout=READ_TIMEOUT)
  94. except ApiError as exc:
  95. raise explain_api_error(exc, f'查词典 {args.word}')
  96. groups = (data or {}).get('words') or []
  97. if not groups:
  98. raise WpError(f'词典里没有「{args.word}」。')
  99. def render():
  100. for grp in groups:
  101. for w in (grp.get('words') or [])[: args.limit]:
  102. print(f'■ {w.get("word")}')
  103. for g in (w.get('grammar') or [])[:6]:
  104. print(f' ← {g.get("parent")} {g.get("type")} {g.get("grammar")}'
  105. f' ({g.get("factors")})')
  106. for d in (w.get('dict') or [])[: args.dicts]:
  107. # 释义在 note;description 是词典本身的介绍,不是词条内容
  108. meaning = strip_markup(d.get('note') or '')
  109. if not meaning:
  110. continue
  111. print(f' 〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}')
  112. print()
  113. emit(args, groups, render)
  114. return 0
  115. # ---------------------------------------------------------------------------
  116. # search —— 按词形检索段落
  117. # ---------------------------------------------------------------------------
  118. def resolve_key(client, args):
  119. """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。"""
  120. if args.lemma:
  121. rows = fetch_forms(client, args.lemma)
  122. if not rows:
  123. raise WpError(f'「{args.lemma}」展不出任何词形。')
  124. key = forms_arg(rows[0])
  125. note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}')
  126. return key
  127. key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip())
  128. if not key:
  129. raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。')
  130. return key
  131. def cmd_search(args):
  132. client = make_client(args)
  133. key = resolve_key(client, args)
  134. query = {'key': key, 'limit': args.limit, 'offset': args.offset}
  135. if args.bold:
  136. query['bold'] = 'on'
  137. if args.book:
  138. query['book'] = args.book
  139. if args.tags:
  140. query['tags'] = args.tags
  141. try:
  142. data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT)
  143. except ApiError as exc:
  144. raise explain_api_error(exc, '检索')
  145. rows = (data or {}).get('rows') or []
  146. total = (data or {}).get('count', 0)
  147. def render():
  148. scope = []
  149. if args.bold:
  150. scope.append('仅黑体')
  151. if args.book:
  152. scope.append(f'book={args.book}')
  153. if args.tags:
  154. scope.append(f'tags={args.tags}')
  155. print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})'
  156. + (f' [{" ".join(scope)}]' if scope else ''))
  157. if not rows:
  158. print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。')
  159. return
  160. print()
  161. for idx, r in enumerate(rows, 1 + args.offset):
  162. coord = fmt_coord(r.get('book'), r.get('paragraph'))
  163. print(f'[{idx}] {coord} {fmt_path(r.get("path"))} rank {r.get("rank")}')
  164. print(f' {snippet(strip_markup(r.get("highlight")), args.width, "【")}')
  165. print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:'
  166. f'{rows[0].get("paragraph")}')
  167. emit(args, {'count': total, 'rows': rows}, render)
  168. return 0
  169. # ---------------------------------------------------------------------------
  170. # dist —— 出处分布
  171. # ---------------------------------------------------------------------------
  172. def cmd_dist(args):
  173. client = make_client(args)
  174. key = resolve_key(client, args)
  175. query = {'key': key}
  176. if args.tags:
  177. query['tags'] = args.tags
  178. try:
  179. data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT)
  180. except ApiError as exc:
  181. raise explain_api_error(exc, '统计出处分布')
  182. rows = (data or {}).get('rows') or []
  183. def render():
  184. total = sum(int(r.get('count') or 0) for r in rows)
  185. print(f'{len(rows)} 部书,共 {total} 次词命中\n'
  186. '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,'
  187. '两者不相等——同一段里出现多次只算一段。)\n')
  188. by_layer = {}
  189. for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]:
  190. layer = text_layer(r.get('tags'))
  191. by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0)
  192. tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name'))
  193. print(f'{r.get("count"):>5} {str(r.get("paliTitle"))[:38]:<40} '
  194. f'--book {r.get("pcdBookId")} [{tags}]')
  195. print('\n按文献层次:', end='')
  196. for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''):
  197. if layer in by_layer:
  198. print(f' {layer or "未标层次"} {by_layer[layer]}', end='')
  199. print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
  200. emit(args, {'rows': rows}, render)
  201. return 0
  202. # ---------------------------------------------------------------------------
  203. # get —— 按坐标取原文/译文
  204. # ---------------------------------------------------------------------------
  205. def cmd_get(args):
  206. client = make_client(args)
  207. grouped = parse_coords(args.coords)
  208. channels = ','.join(args.channel) if args.channel else PALI_CHANNEL
  209. collected = []
  210. for book, paras in grouped.items():
  211. # 服务端不带 channels 会 500,所以 channels 永远要给
  212. query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras),
  213. 'channels': channels, 'limit': args.limit}
  214. try:
  215. data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT)
  216. except ApiError as exc:
  217. raise explain_api_error(exc, f'取 {book} 的段落')
  218. collected.extend((data or {}).get('rows') or [])
  219. def render():
  220. if not collected:
  221. print('这些坐标在指定 channel 下没有内容。')
  222. print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——'
  223. '如实报告,不要拿相邻段落或别的译本凑。')
  224. return
  225. current = None
  226. for r in collected:
  227. ch = (r.get('channel') or {})
  228. head = (r.get('book'), r.get('paragraph'), ch.get('uid'))
  229. if head != current:
  230. current = head
  231. editor = (r.get('editor') or {})
  232. who = editor.get('nickName') or editor.get('name') or ''
  233. print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))} '
  234. f'{ch.get("name")}({ch.get("lang")})'
  235. + (f' 作者:{who}' if who else '') + ' ===')
  236. text = strip_markup(r.get('content'))
  237. print(f' [{r.get("word_start")}-{r.get("word_end")}] {text}')
  238. print(f'\n共 {len(collected)} 句。')
  239. emit(args, collected, render)
  240. return 0
  241. # ---------------------------------------------------------------------------
  242. # toc —— 章节目录
  243. # ---------------------------------------------------------------------------
  244. def cmd_toc(args):
  245. client = make_client(args)
  246. book, para = parse_coord(args.coord)
  247. try:
  248. data = client.call('GET', 'v2/palitext', query={'view': 'book-toc', 'book': book, 'para': para},
  249. timeout=READ_TIMEOUT)
  250. except ApiError as exc:
  251. raise explain_api_error(exc, f'取 {book}:{para} 的章节目录')
  252. rows = (data or {}).get('rows') or []
  253. # 服务端返回的是整套丛书的目录,默认只留当前这本,避免刷屏
  254. shown = rows if args.all else [r for r in rows if r.get('book') == book]
  255. def render():
  256. print(f'{len(rows)} 条目录条目'
  257. + ('' if args.all else f',其中 book {book} 有 {len(shown)} 条(--all 看整套丛书)'))
  258. for r in shown:
  259. level = int(r.get('level') or 1)
  260. if level > args.depth:
  261. continue
  262. print(f'{" " * (level - 1)}{r.get("book")}:{r.get("paragraph")} {r.get("toc")}')
  263. emit(args, shown, render)
  264. return 0
  265. # ---------------------------------------------------------------------------
  266. # chapter —— 先报体量,再取整章
  267. # ---------------------------------------------------------------------------
  268. def fetch_meta(client, book, para):
  269. try:
  270. return client.call('GET', f'v2/palitext/{book}-{para}', timeout=READ_TIMEOUT)
  271. except ApiError as exc:
  272. raise explain_api_error(exc, f'取 {book}:{para} 的段落元信息')
  273. def parse_path(raw):
  274. """这个端点的 path 是 JSON 字符串,search 那边却是数组——两边都要能吃。"""
  275. if isinstance(raw, str):
  276. try:
  277. return json.loads(raw)
  278. except ValueError:
  279. return []
  280. return raw or []
  281. def resolve_chapter(client, book, para):
  282. """给任意段号,向上找到它所属的章节节点。返回 (章节 meta, 走了几层)。
  283. 注意:正文段自己也带 chapter_len(值为 1),所以不能用「有没有这个字段」判断,
  284. 要看它是不是 > 1。向上一层优先取 path 的末项(那就是直接所属的章节),
  285. 没有 path 才退回 parent。
  286. """
  287. meta = fetch_meta(client, book, para)
  288. hops = 0
  289. while meta and int(meta.get('chapter_len') or 0) <= 1 and hops < 6:
  290. up = None
  291. path = parse_path(meta.get('path'))
  292. if path:
  293. last = path[-1]
  294. if int(last.get('paragraph', -1)) != int(meta.get('paragraph', -1)):
  295. up = int(last['paragraph'])
  296. if up is None and meta.get('parent'):
  297. up = int(meta['parent'])
  298. if up is None:
  299. break
  300. meta = fetch_meta(client, book, up)
  301. hops += 1
  302. return meta, hops
  303. def cmd_chapter(args):
  304. client = make_client(args)
  305. book, para = parse_coord(args.coord)
  306. meta, hops = resolve_chapter(client, book, para)
  307. if not meta or not meta.get('chapter_len'):
  308. raise WpError(f'{book}:{para} 向上找不到章节节点,无法确定章节范围。')
  309. start = int(meta['paragraph'])
  310. length = int(meta['chapter_len'])
  311. strlen = int(meta.get('chapter_strlen') or 0)
  312. end = start + length - 1
  313. path = parse_path(meta.get('path'))
  314. title = meta.get('toc') or meta.get('title') or (path[-1].get('title') if path else '')
  315. print(f'章节 : {title}')
  316. print(f'路径 : {fmt_path(path)}')
  317. print(f'范围 : {book}:{start} – {book}:{end}({length} 段)'
  318. + (f',约 {strlen} 字符' if strlen else ''))
  319. if hops:
  320. print(f'({book}:{para} 是正文段,向上 {hops} 层找到所属章节)')
  321. if meta.get('prev_chapter') or meta.get('next_chapter'):
  322. print(f'相邻 : 上一章 {book}:{meta.get("prev_chapter")} 下一章 {book}:{meta.get("next_chapter")}')
  323. if not args.fetch:
  324. print(f'\n只报体量,未取文。确认要读再加 --fetch;只要其中几段用:'
  325. f'wikipali get {book}:{start} {book}:{start + 1} …')
  326. return 0
  327. if strlen > args.warn_at:
  328. note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
  329. args.coords = [f'{book}:{p}' for p in range(start, end + 1)]
  330. args.limit = max(args.limit, length * 20)
  331. return cmd_get(args)
  332. # ---------------------------------------------------------------------------
  333. # versions —— 某坐标有哪些译本,以及没有哪些
  334. # ---------------------------------------------------------------------------
  335. def cmd_versions(args):
  336. client = make_client(args)
  337. book, para = parse_coord(args.coord)
  338. try:
  339. data = client.call('GET', 'v2/channel',
  340. query={'view': 'paragraphs', 'book_id': book, 'para': para},
  341. timeout=READ_TIMEOUT)
  342. except ApiError as exc:
  343. if exc.status and exc.status >= 500:
  344. raise WpError(
  345. f'查 {book}:{para} 的可用译本失败(HTTP {exc.status})。\n'
  346. '稳定版站点上 channel?view=paragraphs 有已知缺陷,修复只在最新版代码上。\n'
  347. '请切到最新版再试:wikipali endpoint next,或本次调用加 --api next。'
  348. )
  349. raise explain_api_error(exc, f'查 {book}:{para} 的可用译本')
  350. rows = (data or {}).get('rows') or []
  351. def render():
  352. if not rows:
  353. print(f'{book}:{para} 在任何 channel 下都没有内容。')
  354. return
  355. print(f'{book}:{para} 有 {len(rows)} 个 channel 存有内容:\n')
  356. by_type = {}
  357. for r in rows:
  358. by_type.setdefault(r.get('type') or '?', []).append(r)
  359. for typ in sorted(by_type):
  360. print(f' [{typ}]')
  361. for r in sorted(by_type[typ], key=lambda x: str(x.get('lang'))):
  362. name_l = (r.get('name') or '').lower()
  363. ai = ' ⚠疑似机器译' if any(h in name_l for h in MACHINE_HINTS) else ''
  364. print(f' {str(r.get("lang")):<8} {str(r.get("name"))[:36]:<38} {r.get("uid")}{ai}')
  365. langs = {str(r.get('lang')) for r in rows}
  366. missing = [l for l in ('pali', 'my', 'zh-Hans', 'zh', 'en', 'th') if l not in langs]
  367. if missing:
  368. print(f'\n该段**没有**这些语言的内容:{", ".join(missing)}')
  369. print('如实报告「无」,不要拿相邻段落或别的译本凑。')
  370. print('\n标 ⚠疑似机器译 的按机器译文标注引用。**没标的不等于是人译**——'
  371. '名字判断很脆弱,权威做法是 wikipali get 看作者是不是模型,见 conventions.md。')
  372. emit(args, rows, render)
  373. return 0
  374. # ---------------------------------------------------------------------------
  375. # count —— 词频合计
  376. # ---------------------------------------------------------------------------
  377. def cmd_count(args):
  378. client = make_client(args)
  379. out = []
  380. for word in args.words:
  381. rows = fetch_forms(client, word)
  382. if not rows:
  383. out.append({'word': word, 'found': False})
  384. continue
  385. top = rows[0]
  386. forms = top.get('case') or []
  387. out.append({
  388. 'word': word, 'found': True, 'lemma': top.get('word'),
  389. 'forms': len(forms),
  390. 'total': sum(int(f.get('count') or 0) for f in forms),
  391. 'bold': sum(int(f.get('bold') or 0) for f in forms),
  392. })
  393. def render():
  394. print(f'{"词":<28}{"词根":<24}{"词形":>5}{"词次":>8}{"黑体":>7}')
  395. for r in out:
  396. if not r['found']:
  397. print(f'{r["word"]:<28}{"(语料中未见)":<24}')
  398. continue
  399. print(f'{r["word"]:<28}{r["lemma"]:<24}{r["forms"]:>5}{r["total"]:>8}{r["bold"]:>7}')
  400. print('\n这里数的是**词次**,不是段落数。段落数用 search 的 count。')
  401. emit(args, out, render)
  402. return 0
  403. # ---------------------------------------------------------------------------
  404. # terms —— 术语表(权威译名对照)
  405. # ---------------------------------------------------------------------------
  406. def terms_cache_path(lang, view):
  407. import os
  408. from creds import CREDS_DIR
  409. return os.path.join(CREDS_DIR, 'cache', f'terms-{view}-{lang}.json')
  410. def cmd_terms(args):
  411. import os
  412. client = make_client(args)
  413. path = terms_cache_path(args.lang, args.view)
  414. rows = None
  415. if os.path.exists(path) and not args.refresh:
  416. try:
  417. with open(path, encoding='utf-8') as fh:
  418. rows = json.load(fh)
  419. except (OSError, ValueError):
  420. rows = None
  421. if rows is None:
  422. note('正在拉取术语表全表(服务端不支持按词查询,只能整表拉后本地过滤)…')
  423. try:
  424. data = client.call('GET', 'v2/term-vocabulary',
  425. query={'view': args.view, 'lang': args.lang}, timeout=120)
  426. except ApiError as exc:
  427. raise explain_api_error(exc, '取术语表')
  428. rows = (data or {}).get('rows') or []
  429. os.makedirs(os.path.dirname(path), exist_ok=True)
  430. with open(path, 'w', encoding='utf-8') as fh:
  431. json.dump(rows, fh, ensure_ascii=False)
  432. note(f'已缓存 {len(rows)} 条到 {path}(--refresh 可强制更新)')
  433. kw = (args.keyword or '').lower()
  434. hits = [r for r in rows if kw in (r.get('word') or '').lower()] if kw else rows
  435. def render():
  436. if not hits:
  437. print(f'术语表({args.view} / {args.lang},共 {len(rows)} 条)里没有含「{args.keyword}」的词条。')
  438. print('注意:这只说明术语表没收录,不代表语料里没有这个词。')
  439. return
  440. print(f'{len(hits)} 条(全表 {len(rows)}):\n')
  441. for r in hits[: args.limit]:
  442. tag = f' [{r["tag"]}]' if r.get('tag') else ''
  443. other = f' / {r["other_meaning"]}' if r.get('other_meaning') else ''
  444. print(f' {r.get("word"):<32} {r.get("meaning")}{other}{tag}')
  445. if len(hits) > args.limit:
  446. print(f' …… 其余 {len(hits) - args.limit} 条(--limit 调整)')
  447. print('\n术语表是**权威译名对照**,写译文或论文时的用词应与它一致;'
  448. '与它不一致时要说明理由。')
  449. emit(args, hits, render)
  450. return 0