codebee 0.1.62 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +507 -490
  2. package/LICENSE +21 -21
  3. package/README.md +487 -486
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/aiflavor.py +197 -197
  12. package/app/core/artifacts.py +148 -0
  13. package/app/core/attachments.py +532 -532
  14. package/app/core/automation.py +758 -758
  15. package/app/core/backup.py +470 -470
  16. package/app/core/beekeeper.py +183 -183
  17. package/app/core/board.py +209 -209
  18. package/app/core/bookmeta.py +215 -19
  19. package/app/core/bookmeta_catalog.py +180 -180
  20. package/app/core/branching.py +137 -137
  21. package/app/core/builtin_agent.py +1191 -1191
  22. package/app/core/capability.py +144 -144
  23. package/app/core/catalog.py +19 -14
  24. package/app/core/chaptersafe.py +32 -32
  25. package/app/core/cleanup.py +303 -303
  26. package/app/core/compaction.py +186 -186
  27. package/app/core/covergen.py +356 -356
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +173 -173
  30. package/app/core/dispatch_log.py +113 -113
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/error_codes.py +64 -64
  33. package/app/core/errorlog.py +137 -137
  34. package/app/core/flows.py +355 -355
  35. package/app/core/goal_service.py +167 -167
  36. package/app/core/health.py +341 -341
  37. package/app/core/history.py +38 -38
  38. package/app/core/hooks.py +156 -156
  39. package/app/core/jobs.py +1022 -1022
  40. package/app/core/manager.py +1996 -1996
  41. package/app/core/market.py +362 -362
  42. package/app/core/market_remote.py +66 -66
  43. package/app/core/mocks.py +64 -64
  44. package/app/core/modelhub.py +3332 -3266
  45. package/app/core/notify.py +106 -106
  46. package/app/core/paihang.py +109 -109
  47. package/app/core/paths.py +2 -2
  48. package/app/core/pipeline.py +4483 -4426
  49. package/app/core/planner.py +27 -27
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/publish/__init__.py +7 -7
  53. package/app/core/publish/auto.py +367 -367
  54. package/app/core/publish/browser.py +533 -533
  55. package/app/core/publish/fanqie.py +128 -128
  56. package/app/core/publish/flow.py +406 -406
  57. package/app/core/publish/ledger.py +198 -198
  58. package/app/core/publish/manager.py +590 -590
  59. package/app/core/publish/qimao.py +141 -141
  60. package/app/core/publish/ws.py +139 -139
  61. package/app/core/redact.py +38 -38
  62. package/app/core/repeat_guard.py +124 -124
  63. package/app/core/router.py +20 -3
  64. package/app/core/runner.py +2019 -1991
  65. package/app/core/selfupdate.py +384 -384
  66. package/app/core/session_log.py +161 -161
  67. package/app/core/sessions.py +312 -312
  68. package/app/core/settings.py +149 -149
  69. package/app/core/settings_schema.py +262 -262
  70. package/app/core/share_page.py +114 -114
  71. package/app/core/skill_scan.py +106 -106
  72. package/app/core/skillpacks/fanqie-novel.md +80 -80
  73. package/app/core/skillpacks/market/character-bible.md +66 -66
  74. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  75. package/app/core/skillpacks/market/git-workflow.md +57 -57
  76. package/app/core/skillpacks/market/release-notes.md +72 -72
  77. package/app/core/skillpacks/market/weekly-report.md +71 -71
  78. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  79. package/app/core/step_runner.py +132 -132
  80. package/app/core/store.py +13 -70
  81. package/app/core/task_compile.py +199 -199
  82. package/app/core/telemetry.py +292 -292
  83. package/app/core/tlsctx.py +64 -64
  84. package/app/core/token_meter.py +147 -147
  85. package/app/core/volumes.py +355 -355
  86. package/app/core/wxdigest.py +858 -858
  87. package/app/core/zentao.py +2447 -2090
  88. package/app/main.py +2832 -2826
  89. package/app/pick_dialog.py +110 -110
  90. package/app/ui/app.js +13977 -13977
  91. package/app/ui/board.html +162 -162
  92. package/app/ui/board.js +305 -305
  93. package/app/ui/i18n.js +2711 -2711
  94. package/app/ui/icons/bee.svg +79 -79
  95. package/app/ui/index.html +1 -1
  96. package/app/ui/manifest.json +16 -16
  97. package/app/ui/qrcode.js +2297 -2297
  98. package/app/ui/style.css +6125 -6125
  99. package/bin/tutti.js +147 -147
  100. package/package.json +41 -41
  101. package/tools/wx_reader.py +261 -261
@@ -4,12 +4,13 @@
4
4
  用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
5
5
  提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
6
6
  book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
7
- 编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,字段留待用户补)。
7
+ 编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,必填字段仍保证可建书)。
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
- import os
12
- import time
11
+ import os
12
+ import re
13
+ import time
13
14
  from pathlib import Path
14
15
 
15
16
  PLATFORMS = {
@@ -25,7 +26,8 @@ def template_only():
25
26
  """只走模板兜底(跳过编排者/作者 CLI):env TUTTI_BOOKMETA_TEMPLATE_ONLY=1。
26
27
 
27
28
  离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
28
- 产出仍是结构完整的字段,只是书名/简介直接取大纲与目标,其余留「待补充」。"""
29
+ 产出仍是结构完整的字段,书名/简介直接取大纲与目标,平台必填字段由生成阶段
30
+ 的官方目录兜底补齐。"""
29
31
  return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
30
32
 
31
33
 
@@ -376,7 +378,7 @@ def _material_text(task, outline):
376
378
  return material
377
379
 
378
380
 
379
- def _fill_tag_gaps(task, platform, meta, outline):
381
+ def _fill_tag_gaps(task, platform, meta, outline):
380
382
  """llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
381
383
 
382
384
  模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
@@ -400,7 +402,137 @@ def _fill_tag_gaps(task, platform, meta, outline):
400
402
  for key, table, limit in pairs:
401
403
  if not meta.get(key):
402
404
  meta[key] = _scan_table(material, table, limit)
403
- return meta
405
+ return meta
406
+
407
+
408
+ def _material_names(material):
409
+ """从圣经/大纲/章节里提取明确标注的人物名。
410
+
411
+ 只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
412
+ 普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
413
+ """
414
+ text = str(material or "")
415
+ out = []
416
+ patterns = (
417
+ r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
418
+ r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
419
+ r"人物\s*[::]\s*([^\n。;;]{2,40})",
420
+ )
421
+ for pat in patterns:
422
+ for m in re.finditer(pat, text):
423
+ raw = m.group(1)
424
+ chunks = re.split(r"[、,,/和及与]|\s+", raw)
425
+ for chunk in chunks:
426
+ name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
427
+ if 2 <= len(name) <= 8 and name not in out:
428
+ out.append(name[:5])
429
+ if len(out) >= 2:
430
+ return out
431
+ return out
432
+
433
+
434
+ def _pick_option(material, table, keywords=()):
435
+ """从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
436
+ text = str(material or "")
437
+ for item in table:
438
+ if len(item) >= 2 and item in text:
439
+ return item
440
+ for key, item in keywords:
441
+ if key and key in text and item in table:
442
+ return item
443
+ return table[0] if table else ""
444
+
445
+
446
+ def _has_value(value):
447
+ """判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
448
+ placeholders = {"待补充", "(待补充)", "(待补充)"}
449
+ if isinstance(value, list):
450
+ return any((text := str(x or "").strip()) and text not in placeholders
451
+ for x in value)
452
+ text = str(value or "").strip()
453
+ return bool(text) and text not in placeholders
454
+
455
+
456
+ def _fill_required_fields(task, platform, meta, outline, material):
457
+ """生成阶段的必填字段闸门。
458
+
459
+ ``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
460
+ 这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
461
+ 屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
462
+ """
463
+ from . import bookmeta_catalog as cat
464
+
465
+ material = str(material or "")
466
+ names = _material_names(material)
467
+ female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
468
+ female = any(x in ((task.get("goal") or "") + material) for x in female_words)
469
+ defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
470
+ if not _has_value(meta.get("protagonist_1")):
471
+ meta["protagonist_1"] = names[0] if names else defaults[0]
472
+ if not _has_value(meta.get("protagonist_2")):
473
+ candidate = names[1] if len(names) > 1 else defaults[1]
474
+ if candidate == meta["protagonist_1"]:
475
+ candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
476
+ meta["protagonist_2"] = candidate
477
+
478
+ if platform == "fanqie":
479
+ reader = meta.get("target_reader") or ("女频" if female else "男频")
480
+ if reader not in ("男频", "女频"):
481
+ reader = "女频" if female else "男频"
482
+ meta["target_reader"] = reader
483
+ cats, theme, role, plot = _fq_tables(reader)
484
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
485
+ if meta.get("category") not in cats:
486
+ meta["category"] = _pick_option(
487
+ material, cats,
488
+ (("戍边", "古风世情"), ("边关", "古风世情"),
489
+ ("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
490
+ ("种田", "种田"), ("都市", "都市脑洞")))
491
+ groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
492
+ ("tags_plot", plot, 2), ("content_plot", c_plot, 4),
493
+ ("content_emotion", c_emo, 2), ("content_character", c_char, 4),
494
+ ("content_world", c_world, 1))
495
+ else:
496
+ reader = meta.get("target_reader") or ("女生" if female else "男生")
497
+ if reader not in ("男生", "女生"):
498
+ reader = "女生" if female else "男生"
499
+ meta["target_reader"] = reader
500
+ channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
501
+ main = meta.get("category_main")
502
+ if main not in channel:
503
+ main = _pick_option(
504
+ material, list(channel),
505
+ (("戍边", "古代言情"), ("边关", "古代言情"),
506
+ ("古代", "古代言情"), ("军事", "军事"),
507
+ ("军", "军事"), ("都市", "都市"),
508
+ ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
509
+ meta["category_main"] = main
510
+ subs = channel.get(main) or []
511
+ sub = meta.get("category_sub")
512
+ if sub not in subs:
513
+ located = cat.qimao_locate_sub(sub)
514
+ if located:
515
+ reader, main = located
516
+ meta["target_reader"] = reader
517
+ meta["category_main"] = main
518
+ subs = cat.QIMAO_CATS[reader][main]
519
+ else:
520
+ sub = ""
521
+ if sub not in subs:
522
+ meta["category_sub"] = _pick_option(material, subs)
523
+ groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
524
+ ("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
525
+ ("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
526
+ ("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
527
+ for key, table, limit in groups:
528
+ values = meta.get(key)
529
+ if not isinstance(values, list):
530
+ values = []
531
+ values = [str(v).strip() for v in values if str(v or "").strip() in table]
532
+ if not values:
533
+ values = [_pick_option(material, table)]
534
+ meta[key] = values[:limit]
535
+ return meta
404
536
 
405
537
 
406
538
  def store_read_bible(task):
@@ -455,11 +587,12 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
455
587
  planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
456
588
  provider=prov.get("name", prov.get("id", "")),
457
589
  provider_id=prov.get("id", ""))
458
- if res["ok"]:
459
- meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
460
- if meta:
461
- return _fill_tag_gaps(task, platform, meta, outline), \
462
- "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
590
+ if res["ok"]:
591
+ meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
592
+ if meta:
593
+ meta = _fill_tag_gaps(task, platform, meta, outline)
594
+ return _fill_required_fields(task, platform, meta, outline, material), \
595
+ "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
463
596
  orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
464
597
 
465
598
  if author_agent and author_agent.get("mode") == "real" and not template_only():
@@ -467,14 +600,16 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
467
600
  workdir=task.get("workdir"), readonly=True,
468
601
  timeout=BOOKMETA_TIMEOUT, log_path=log_path)
469
602
  planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
470
- if res["ok"]:
471
- meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
472
- if meta:
473
- return _fill_tag_gaps(task, platform, meta, outline), "llm(%s)" % author_agent["id"]
603
+ if res["ok"]:
604
+ meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
605
+ if meta:
606
+ meta = _fill_tag_gaps(task, platform, meta, outline)
607
+ return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
474
608
  orch_err = orch_err or str(res.get("error") or "")[:200]
475
609
 
476
- meta = _template_meta(task, platform, outline)
477
- meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
610
+ meta = _template_meta(task, platform, outline)
611
+ meta = _fill_required_fields(task, platform, meta, outline, material)
612
+ meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
478
613
  return meta, meta["source"]
479
614
 
480
615
 
@@ -528,7 +663,7 @@ def generate_async(task_id, platform, author_agent=None):
528
663
  store.set_book_meta(task_id, platform, entry)
529
664
 
530
665
 
531
- def recover_orphans():
666
+ def recover_orphans():
532
667
  """启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
533
668
  前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
534
669
  遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
@@ -542,4 +677,65 @@ def recover_orphans():
542
677
  "error": "生成随服务重启中断,请点重试",
543
678
  "at": time.strftime("%Y-%m-%d %H:%M:%S")})
544
679
  n += 1
545
- return n
680
+ return n
681
+
682
+
683
+ def repair_existing():
684
+ """补齐历史已生成记录中的必填字段。
685
+
686
+ 旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
687
+ 目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
688
+ 和升级诊断使用。
689
+ """
690
+ from . import store
691
+ repaired = 0
692
+ for task in store.list_tasks(limit=10 ** 9):
693
+ book_meta = task.get("book_meta") or {}
694
+ for platform in PLATFORMS:
695
+ entry = book_meta.get(platform) or {}
696
+ if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
697
+ continue
698
+ data = dict(entry["data"])
699
+ if platform == "fanqie":
700
+ reader = data.get("target_reader")
701
+ cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
702
+ required_ok = (
703
+ data.get("category") in cats
704
+ and all(_has_value(data.get(key)) for key in (
705
+ "tags_theme", "tags_role", "tags_plot", "content_plot",
706
+ "content_emotion", "content_character", "content_world"))
707
+ and _has_value(data.get("protagonist_1"))
708
+ and _has_value(data.get("protagonist_2")))
709
+ else:
710
+ from . import bookmeta_catalog as cat
711
+ reader = data.get("target_reader")
712
+ channel = cat.QIMAO_CATS.get(reader) or {}
713
+ main = data.get("category_main")
714
+ required_ok = (
715
+ main in channel
716
+ and data.get("category_sub") in (channel.get(main) or [])
717
+ and all(_has_value(data.get(key)) for key in (
718
+ "tags_style", "tags_role", "tags_plot", "tags_bg"))
719
+ and _has_value(data.get("protagonist_1"))
720
+ and _has_value(data.get("protagonist_2")))
721
+ if required_ok:
722
+ continue
723
+ try:
724
+ material, outline = _collect_material(task)
725
+ fixed = _fill_required_fields(task, platform, data, outline, material)
726
+ if fixed == entry["data"]:
727
+ continue
728
+ payload = dict(entry)
729
+ payload["data"] = fixed
730
+ payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
731
+ store.set_book_meta(task["id"], platform, payload)
732
+ try:
733
+ fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
734
+ if fp.parent.is_dir():
735
+ fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
736
+ except OSError:
737
+ pass
738
+ repaired += 1
739
+ except Exception:
740
+ continue
741
+ return repaired