cmd_write.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. """写入路径的子命令:ensure-model / revoke / channels / grant / write。"""
  2. import json
  3. import sys
  4. import time
  5. from datetime import datetime, timezone
  6. from client import (WRITE_TIMEOUT, TOKEN_REFRESH_MARGIN, DEFAULT_BATCH,
  7. fmt_ts, make_client, mask, note, token_expiry)
  8. from errors import ApiError, WpError, explain_api_error
  9. def cmd_ensure_model(args):
  10. client = make_client(args)
  11. token = client.user_token
  12. name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
  13. if not name:
  14. raise WpError(
  15. "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
  16. "该名字会成为句子的作者署名,不要用别的模型的名字。"
  17. )
  18. try:
  19. current = client.call("GET", "v2/auth/current", token=token)
  20. except ApiError as exc:
  21. raise explain_api_error(exc, "取当前用户信息")
  22. studio_name = current.get("realName")
  23. if not studio_name:
  24. raise WpError("服务端没有返回 realName,无法确定 studio_name。")
  25. # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
  26. try:
  27. listed = client.call(
  28. "GET", "v2/ai-model", token=token,
  29. query={"view": "studio", "name": studio_name, "keyword": name},
  30. )
  31. except ApiError as exc:
  32. raise explain_api_error(exc, "查询模型列表")
  33. rows = (listed or {}).get("rows") or []
  34. found = next((r for r in rows if r.get("name") == name), None)
  35. if found:
  36. print(f"已存在模型记录:{name} uid={found['uid']}")
  37. else:
  38. body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
  39. for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
  40. if value is not None:
  41. body[field] = value
  42. try:
  43. found = client.call("POST", "v2/ai-model", token=token, body=body)
  44. print(f"已创建模型记录:{name} uid={found['uid']}")
  45. except ApiError as exc:
  46. if exc.status != 409:
  47. raise explain_api_error(exc, "创建模型记录")
  48. # 并发或模糊匹配漏网:重查一次
  49. listed = client.call(
  50. "GET", "v2/ai-model", token=token,
  51. query={"view": "studio", "name": studio_name, "keyword": name},
  52. )
  53. rows = (listed or {}).get("rows") or []
  54. found = next((r for r in rows if r.get("name") == name), None)
  55. if not found:
  56. raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
  57. print(f"已存在模型记录:{name} uid={found['uid']}")
  58. # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
  59. patch = {}
  60. for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
  61. if value is not None and found.get(field) != value:
  62. patch[field] = value
  63. if args.privacy and found.get("privacy") != args.privacy:
  64. patch["privacy"] = args.privacy
  65. if patch:
  66. try:
  67. found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
  68. print(f"已更新字段:{', '.join(patch)}")
  69. except ApiError as exc:
  70. raise explain_api_error(exc, "更新模型记录")
  71. # 3) 取模型身份 token
  72. try:
  73. issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
  74. except ApiError as exc:
  75. raise explain_api_error(exc, "签发模型身份 token")
  76. client.bucket["model"] = {
  77. "uid": issued["uid"],
  78. "name": issued["name"],
  79. "token": issued["token"],
  80. "issued_at": iso_now(),
  81. }
  82. client.save()
  83. exp = token_expiry(issued["token"])
  84. print(f"模型身份 token 已缓存:{mask(issued['token'])} 到期 {fmt_ts(exp)}")
  85. print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
  86. return 0
  87. def cmd_revoke(args):
  88. client = make_client(args)
  89. model = client.bucket.get("model") or {}
  90. uid = args.uid or model.get("uid")
  91. if not uid:
  92. raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
  93. if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
  94. print("已取消。")
  95. return 1
  96. try:
  97. data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
  98. except ApiError as exc:
  99. raise explain_api_error(exc, "撤销模型 token")
  100. if model.get("uid") == uid:
  101. client.bucket["model"] = {"uid": uid, "name": model.get("name")}
  102. client.save()
  103. print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
  104. print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
  105. return 0
  106. def fetch_channels(client, search=None):
  107. try:
  108. data = client.call(
  109. "GET", "v2/channel", token=client.user_token,
  110. query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
  111. )
  112. except ApiError as exc:
  113. raise explain_api_error(exc, "获取可编辑 channel 列表")
  114. return (data or {}).get("rows") or []
  115. def cmd_channels(args):
  116. client = make_client(args)
  117. rows = fetch_channels(client, args.search)
  118. if args.json:
  119. print(json.dumps(rows, ensure_ascii=False, indent=2))
  120. return 0
  121. if not rows:
  122. print("当前账号没有任何可编辑的 channel。")
  123. return 1
  124. print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
  125. for idx, ch in enumerate(rows, 1):
  126. print(
  127. f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
  128. f"{ch.get('uid', '')[:8]}… {ch.get('role', '')}"
  129. )
  130. return 0
  131. def pick_channel(client, given, interactive=True):
  132. """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
  133. rows = fetch_channels(client)
  134. if not rows:
  135. raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
  136. if given:
  137. for ch in rows:
  138. if ch.get("uid") == given:
  139. return ch["uid"], ch.get("name")
  140. if given.isdigit() and 1 <= int(given) <= len(rows):
  141. ch = rows[int(given) - 1]
  142. return ch["uid"], ch.get("name")
  143. matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
  144. if len(matched) == 1:
  145. return matched[0]["uid"], matched[0].get("name")
  146. if len(matched) > 1:
  147. names = ", ".join(c.get("name", "") for c in matched[:5])
  148. raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
  149. # 不在可编辑列表里的 uid:直接用,但回显不出名字
  150. if len(given) >= 32:
  151. note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
  152. return given, None
  153. raise WpError(f"找不到 channel:{given}")
  154. if not (interactive and sys.stdin.isatty()):
  155. raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
  156. print("可编辑 channel:")
  157. for idx, ch in enumerate(rows, 1):
  158. print(f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
  159. raw = input("选择序号:").strip()
  160. if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
  161. raise WpError("输入无效。")
  162. ch = rows[int(raw) - 1]
  163. return ch["uid"], ch.get("name")
  164. def cached_access_token(client, channel_uid, book):
  165. item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
  166. if not item or not item.get("token"):
  167. return None
  168. # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
  169. if item.get("book", 0) != 0 and item.get("book") != book:
  170. return None
  171. exp = item.get("exp") or token_expiry(item["token"])
  172. if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
  173. return None
  174. return item
  175. def grant_access_token(client, channel_uid, channel_name, book, force=False):
  176. if not force:
  177. cached = cached_access_token(client, channel_uid, book)
  178. if cached:
  179. return cached
  180. # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
  181. payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
  182. try:
  183. data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
  184. except ApiError as exc:
  185. raise explain_api_error(exc, "签发 access token")
  186. rows = (data or {}).get("rows") or []
  187. if not rows:
  188. # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
  189. raise WpError(
  190. f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
  191. "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
  192. )
  193. row = rows[0]
  194. item = {
  195. "token": row["token"],
  196. "book": int(book),
  197. "exp": (row.get("payload") or {}).get("exp"),
  198. "granted_at": iso_now(),
  199. }
  200. if channel_name:
  201. item["channel_name"] = channel_name
  202. client.bucket.setdefault("access_tokens", {})[channel_uid] = item
  203. client.save()
  204. return item
  205. def cmd_grant(args):
  206. client = make_client(args)
  207. uid, name = pick_channel(client, args.channel)
  208. item = grant_access_token(client, uid, name, args.book, force=args.force)
  209. scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
  210. print(f"channel : {name or '(未知)'} {uid}")
  211. print(f"范围 : {scope}")
  212. print(f"token : {mask(item['token'])} 到期 {fmt_ts(item.get('exp'))}")
  213. return 0
  214. SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
  215. def load_sentences(args):
  216. if args.file == "-":
  217. raw = sys.stdin.read()
  218. else:
  219. try:
  220. with open(args.file, "r", encoding="utf-8") as fh:
  221. raw = fh.read()
  222. except OSError as exc:
  223. raise WpError(f"读不了输入文件:{exc}")
  224. try:
  225. data = json.loads(raw)
  226. except ValueError as exc:
  227. raise WpError(f"输入不是合法 JSON:{exc}")
  228. default_channel = None
  229. if isinstance(data, dict):
  230. default_channel = data.get("channel_uid") or data.get("channel")
  231. data = data.get("sentences")
  232. if not isinstance(data, list) or not data:
  233. raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
  234. return data, default_channel
  235. def normalize_sentences(rows, channel_uid, default_content_type):
  236. out = []
  237. for idx, row in enumerate(rows):
  238. if not isinstance(row, dict):
  239. raise WpError(f"第 {idx + 1} 条不是对象。")
  240. missing = [f for f in SENT_REQUIRED if row.get(f) is None]
  241. if missing:
  242. raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
  243. try:
  244. sent = {
  245. "book_id": int(row["book_id"]),
  246. "paragraph": int(row["paragraph"]),
  247. "word_start": int(row["word_start"]),
  248. "word_end": int(row["word_end"]),
  249. "content": str(row["content"]),
  250. "content_type": row.get("content_type") or default_content_type,
  251. "channel_uid": row.get("channel_uid") or channel_uid,
  252. }
  253. except (TypeError, ValueError) as exc:
  254. raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
  255. if not sent["channel_uid"]:
  256. raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
  257. out.append(sent)
  258. return out
  259. def sent_key(sent):
  260. return (
  261. int(sent["book_id"]),
  262. int(sent["paragraph"]),
  263. int(sent["word_start"]),
  264. int(sent["word_end"]),
  265. sent["channel_uid"],
  266. )
  267. def row_key(row):
  268. channel = row.get("channel") or {}
  269. return (
  270. int(row.get("book", -1)),
  271. int(row.get("paragraph", -1)),
  272. int(row.get("word_start", -1)),
  273. int(row.get("word_end", -1)),
  274. channel.get("uid"),
  275. )
  276. def confirm(question):
  277. if not sys.stdin.isatty():
  278. return False
  279. answer = input(f"{question} [y/N] ").strip().lower()
  280. return answer in ("y", "yes")
  281. def iso_now():
  282. return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  283. def cmd_write(args):
  284. client = make_client(args)
  285. # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
  286. client.user_token
  287. model = client.model
  288. rows, file_channel = load_sentences(args)
  289. channel_hint = args.channel or file_channel
  290. uid = name = None
  291. if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
  292. uid, name = pick_channel(client, channel_hint)
  293. sentences = normalize_sentences(rows, uid, args.content_type)
  294. channels = sorted({s["channel_uid"] for s in sentences})
  295. books = sorted({s["book_id"] for s in sentences})
  296. names = {uid: name} if uid else {}
  297. for cuid in channels:
  298. if cuid not in names:
  299. names[cuid] = channel_display_name(client, cuid)
  300. # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
  301. print("=" * 72)
  302. print(f"API : {client.api_note()}")
  303. for cuid in channels:
  304. print(f"channel : {names.get(cuid) or '(未知)'} {cuid}")
  305. print(f"book : {', '.join(str(b) for b in books)}")
  306. print(f"模型身份 : {model.get('name')} uid={model.get('uid')}")
  307. print(f"句子数 : {len(sentences)}(每批 {args.batch})")
  308. print("-" * 72)
  309. for sent in sentences[: args.preview]:
  310. summary = sent["content"].replace("\n", " ")
  311. if len(summary) > 50:
  312. summary = summary[:50] + "…"
  313. print(f" {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']} {summary}")
  314. if len(sentences) > args.preview:
  315. print(f" …… 其余 {len(sentences) - args.preview} 条")
  316. print("-" * 72)
  317. print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
  318. print("=" * 72)
  319. if args.dry_run:
  320. print("--dry-run:未发送任何请求。")
  321. return 0
  322. if not args.yes and not confirm("确认写入?"):
  323. print("已取消,未写入任何内容。")
  324. return 1
  325. # 每个 channel 一张 access token(缓存命中就不重签)
  326. tokens = {}
  327. for cuid in channels:
  328. book_scope = 0 if len(books) > 1 else books[0]
  329. if args.book is not None:
  330. book_scope = args.book
  331. item = grant_access_token(client, cuid, names.get(cuid), book_scope)
  332. tokens[cuid] = item["token"]
  333. written = {}
  334. failed = []
  335. model_token = model["token"]
  336. for start in range(0, len(sentences), args.batch):
  337. batch = sentences[start : start + args.batch]
  338. body = {
  339. "sentences": [
  340. {
  341. "book_id": s["book_id"],
  342. "paragraph": s["paragraph"],
  343. "word_start": s["word_start"],
  344. "word_end": s["word_end"],
  345. "channel_uid": s["channel_uid"],
  346. "content": s["content"],
  347. "content_type": s["content_type"],
  348. "access_token": tokens[s["channel_uid"]],
  349. }
  350. for s in batch
  351. ]
  352. }
  353. try:
  354. data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
  355. except ApiError as exc:
  356. if exc.status != 401:
  357. raise explain_api_error(exc, "写入句子")
  358. # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
  359. model_token = refresh_model_token(client)
  360. try:
  361. data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
  362. except ApiError as retry_exc:
  363. raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
  364. returned = (data or {}).get("rows") or []
  365. for row in returned:
  366. written[row_key(row)] = row
  367. got = len(returned)
  368. print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
  369. if got < len(batch):
  370. # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
  371. for s in batch:
  372. if sent_key(s) not in written:
  373. failed.append(s)
  374. print("-" * 72)
  375. print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
  376. sample = next(iter(written.values()), None)
  377. if sample:
  378. editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
  379. print(f"署名核对:第一条的 editor = {editor}")
  380. if failed:
  381. print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
  382. for s in failed[:10]:
  383. print(f" {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']} channel={s['channel_uid'][:8]}…")
  384. if len(failed) > 10:
  385. print(f" …… 其余 {len(failed) - 10} 条")
  386. return 1
  387. return 0
  388. def channel_display_name(client, uid):
  389. try:
  390. data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
  391. except (ApiError, WpError):
  392. return None
  393. if isinstance(data, dict):
  394. return data.get("name")
  395. return None
  396. def refresh_model_token(client):
  397. note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
  398. model = client.bucket.get("model") or {}
  399. if not model.get("uid"):
  400. raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
  401. try:
  402. issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
  403. except ApiError as exc:
  404. raise explain_api_error(exc, "重新签发模型 token")
  405. model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
  406. client.bucket["model"] = model
  407. client.save()
  408. return issued["token"]