decompose.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """创作知识解构引擎 v2:一帖 → N 颗(how/what/why,含组件颗)→ frameworks.json + payloads.json。
  2. 编排全流程,把 skill 的 phase 文档当 prompt 喂给 LLM(skill 是唯一真源):
  3. ① 读懂:图文帖→extractor 读图;视频帖→video_extract 下载 mp4+原生整段提炼(base64→Gemini)
  4. ② 判颗+类型闸+三lane成形+轻标签:system = phase1-frame.md
  5. ③ 作用域:system = phase2-scope.md → 候选 → scope_link 回扣(火山)
  6. ⑤ 组装:代码 → 每颗一个 ingest payload(按类型分拼)
  7. ① 判 is_empty=true(无可提取知识)→ 短路,跳过 ②③⑤。
  8. 数据源:fixture(已有 5 帖)或实时 crawler 取数(新帖)。须在云端跑。用法:PYTHONPATH=. python scripts/decompose.py
  9. """
  10. from __future__ import annotations
  11. import json
  12. from pathlib import Path
  13. from creation_knowledge.config import Settings
  14. from creation_knowledge.integrations import video_extract
  15. from creation_knowledge.integrations.crawler import fetch_post_detail, parse_detail_response
  16. from creation_knowledge.integrations.extractor import GeminiExtractor
  17. from creation_knowledge.integrations.llm import chat_json
  18. from scripts.scope_link import ScopeLinker
  19. ROOT = Path(__file__).resolve().parent.parent
  20. FIX = ROOT / "tests" / "fixtures"
  21. DATA = ROOT / "data" / "demo"
  22. SKILL = ROOT / "创作知识提取-skill"
  23. PHASE1 = (SKILL / "extraction" / "phase1-frame.md").read_text(encoding="utf-8")
  24. PHASE2 = (SKILL / "extraction" / "phase2-scope.md").read_text(encoding="utf-8")
  25. # from: fixture(读 tests/fixtures)/ live(实时 crawler 取数)
  26. SOURCES = [
  27. {"cid": "699308fa0000000016009697", "platform": "xiaohongshu", "from": "fixture"},
  28. {"cid": "698481e1000000000a02a7c1", "platform": "xiaohongshu", "from": "fixture"},
  29. {"cid": "67e2e39b0000000003028ff0", "platform": "xiaohongshu", "from": "fixture"},
  30. {"cid": "680659e8000000001a007a11", "platform": "xiaohongshu", "from": "fixture"},
  31. {"cid": "67e4bdf50000000006028a59", "platform": "xiaohongshu", "from": "fixture"},
  32. {"cid": "7589257893544165455", "platform": "douyin", "from": "live"}, # 抖音视频
  33. {"cid": "6a33655e000000000f0055af", "platform": "xiaohongshu", "from": "live"}, # 无知识
  34. ]
  35. SRC2CN = {"substance": "实质", "form": "形式", "feeling": "感受", "effect": "作用", "intent": "意图"}
  36. TYPE2ATTR = {"how": "how工序", "what": "what构成", "why": "why原理"}
  37. CSTAGE = {"定向", "构思", "结构", "成文", "打磨"} # 创作阶段受控 5 值
  38. REUSE_THRESHOLD = 0.90
  39. # ---------- 取数 ----------
  40. def load_post(src: dict, settings: Settings):
  41. if src["from"] == "fixture":
  42. resp = json.loads((FIX / f"xhs_case_{src['cid']}.json").read_text("utf-8"))
  43. post = parse_detail_response(resp, fallback_content_id=src["cid"])
  44. else:
  45. post = fetch_post_detail(src["cid"], settings=settings)
  46. if not post.url:
  47. post.url = f"https://www.xiaohongshu.com/explore/{src['cid']}"
  48. return post
  49. # ---------- ① 读懂(图文/视频分流) ----------
  50. def read_one(src: dict, post, settings: Settings, extractor: GeminiExtractor):
  51. cid = src["cid"]
  52. if post.video_urls: # 视频帖:下载 mp4 + 原生整段提炼
  53. save = DATA / "douyin" / f"dy_{cid}" / "video.mp4"
  54. pub = f"/data/demo/douyin/dy_{cid}/video.mp4"
  55. ec = video_extract.extract_video(post, settings=settings, save_path=save, public_url=pub)
  56. media = {"type": "video", "video_url": pub, "images": []}
  57. cmap = {c.index: c.content for c in ec.cards}
  58. cards = [{"index": c.index, "content": cmap.get(c.index, ""), "video_url": pub,
  59. "start": c.start, "end": c.end} for c in post.cards] # 段卡:时间戳 + 读到的内容
  60. else: # 图文帖:读图
  61. ec = extractor.extract(post)
  62. if src["from"] == "fixture":
  63. imgs = [f"/data/demo/xiaohongshu/{post.id}/image_{n}.webp" for n in range(1, len(post.image_urls) + 1)]
  64. else:
  65. imgs = list(post.image_urls) # 新帖未落盘 → 直接用 CDN url
  66. media = {"type": "image", "video_url": None, "images": imgs}
  67. cmap = {c.index: c.content for c in ec.cards}
  68. cards = [{"index": n, "content": cmap.get(n, ""), "image_url": imgs[n - 1] if n <= len(imgs) else None}
  69. for n in range(1, len(imgs) + 1)] # 每图一张卡:图 url + 读到的内容
  70. parts = [ec.text]
  71. if ec.from_image:
  72. parts.append("【图片要点】\n" + ec.from_image)
  73. parts += [f"【卡片{c.index}】{c.content}" for c in ec.cards if c.content]
  74. return "\n\n".join(p for p in parts if p), bool(ec.is_empty), media, cards
  75. # ---------- ② 判颗+成形+轻标签 ----------
  76. def shape(post, read: str) -> list[dict]:
  77. user = (f"原帖标题:{post.title or '(无)'}\n\n读懂后的完整内容:\n{read}\n\n"
  78. "按上面规则拆颗+判类型+成形+轻标签。作用域字段一律留空 []。"
  79. "只输出 JSON:{\"knowledges\":[ ... 见模板 ... ]}")
  80. return chat_json(PHASE1, user, timeout=120).get("knowledges") or []
  81. # ---------- ③ 作用域候选 + 回扣 ----------
  82. def _slim(knowledges: list[dict]) -> list[dict]:
  83. slim = []
  84. for k in knowledges:
  85. e = {"id": k.get("id"), "type": k.get("type"), "title": k.get("title")}
  86. if k.get("type") == "how":
  87. e["steps"] = [{"id": s.get("id"), "intent": s.get("intent"),
  88. "directive": (s.get("directive") or "")[:500], "output": s.get("output")}
  89. for s in k.get("steps", [])]
  90. else:
  91. e["内容"] = {x: k.get(x) for x in ("界定", "主体", "主张", "支撑") if k.get(x)}
  92. slim.append(e)
  93. return slim
  94. def scope_candidates(knowledges: list[dict]) -> list:
  95. user = ("给下面每颗知识标作用域候选(how 逐步:每个 step 一组;what/why 颗级:整颗一组)。\n"
  96. "只输出 JSON:{\"scopes\":[{\"knowledge_id\":\"k1\",\"step_id\":\"s1\",\"items\":[{\"scope_type\":\"substance\",\"value\":\"…\"}]},"
  97. "{\"knowledge_id\":\"k2\",\"step_id\":null,\"items\":[...]}]}\n\n"
  98. + json.dumps(_slim(knowledges), ensure_ascii=False))
  99. return chat_json(PHASE2, user, timeout=120).get("scopes") or []
  100. def link_scope(linker: ScopeLinker, scope_type: str, value: str) -> dict:
  101. try:
  102. hits = linker.link(value, source_type=SRC2CN.get(scope_type, scope_type), top_k=3)
  103. except Exception:
  104. hits = []
  105. top = hits[0] if hits else {}
  106. score = float(top.get("score", 0.0))
  107. reuse = score >= REUSE_THRESHOLD and top.get("name")
  108. return {"scope_type": scope_type, "value": top["name"] if reuse else value,
  109. "candidate": value, "link": "复用" if reuse else "新建", "score": round(score, 4),
  110. "top": [{"name": h["name"], "score": h["score"], "path": h.get("path", "")} for h in hits]}
  111. def apply_scopes(knowledges: list[dict], scopes: list, linker: ScopeLinker) -> None:
  112. by_k = {k.get("id"): k for k in knowledges}
  113. for sc in scopes:
  114. k = by_k.get(sc.get("knowledge_id"))
  115. if not k:
  116. continue
  117. linked = [link_scope(linker, it["scope_type"], it["value"])
  118. for it in (sc.get("items") or []) if it.get("scope_type") and it.get("value")]
  119. if k.get("type") == "how" and sc.get("step_id"):
  120. for s in k.get("steps", []):
  121. if s.get("id") == sc["step_id"]:
  122. s["作用域"] = linked
  123. else:
  124. k["作用域"] = (k.get("作用域") or []) + linked
  125. # ---------- ⑤ 组装 ----------
  126. def build_content(k: dict) -> str:
  127. t = k.get("type")
  128. if t == "how":
  129. lines = [f"目标:{k.get('purpose','')}"]
  130. for i, s in enumerate(k.get("steps", []), 1):
  131. lines += [f"步骤{i}(目的:{s.get('intent','')})",
  132. f" 指引:{s.get('directive','')}", f" 产出:{s.get('output','')}"]
  133. return "\n".join(lines)
  134. if t == "what":
  135. return "\n".join([f"界定:{k.get('界定','')}"] + _sections(k.get("主体")))
  136. return "\n".join([f"主张:{k.get('主张','')}"] + _sections(k.get("支撑")))
  137. def _sections(blocks) -> list[str]:
  138. """把 what.主体 / why.支撑 的自由小节拼成文本行。"""
  139. out = []
  140. for b in blocks or []:
  141. head = b.get("小标题") or ""
  142. form = b.get("形式")
  143. out.append(f"【{head}】" + (f"({form})" if form else ""))
  144. if b.get("内容"):
  145. out.append(f" {b['内容']}")
  146. for it in b.get("条目") or []:
  147. word = it.get("词") or it.get("要素") or ""
  148. out.append(f" - {word}:{it.get('说明','')}" if word else f" - {it.get('说明','')}")
  149. return out
  150. def build_payload(post, k: dict, how_titles: dict | None = None) -> dict:
  151. how_titles = how_titles or {}
  152. t = k.get("type")
  153. scopes, seen = [], set()
  154. def add(lst):
  155. for sc in lst:
  156. key = (sc["scope_type"], sc["value"])
  157. if key not in seen:
  158. seen.add(key); scopes.append({"scope_type": sc["scope_type"], "value": sc["value"]})
  159. if t == "how":
  160. for s in k.get("steps", []):
  161. add(s.get("作用域", []))
  162. else:
  163. add(k.get("作用域", []))
  164. ext = [{"key": "业务阶段", "type": "str", "value": v} for v in (k.get("业务阶段") or [])]
  165. if t == "how":
  166. cs, cseen = [], set()
  167. for s in k.get("steps", []):
  168. c = s.get("创作阶段")
  169. if c and c not in cseen:
  170. cseen.add(c); cs.append(c)
  171. ext += [{"key": "创作阶段", "type": "str", "value": v} for v in cs]
  172. ext += [{"key": "动作", "type": "str", "value": s["动作"]} for s in k.get("steps", []) if s.get("动作")]
  173. if k.get("role") == "组件" and k.get("parent"):
  174. p = k["parent"]
  175. ext.append({"key": "出自", "type": "str",
  176. "value": f"{how_titles.get(p.get('how_id'), p.get('how_id'))} 第{p.get('step')}步"})
  177. return {"source": {"id": post.id, "source_type": "post", "title": post.title or "",
  178. "author": post.author_name or "", "source_metadata": {"platform": post.platform, "url": post.url}},
  179. "title": k.get("title"), "content": build_content(k),
  180. "dim_creations": ["创作"], "dim_attributes": [TYPE2ATTR.get(t, "how工序")],
  181. "scopes": scopes, "custom_ext": ext}
  182. def main() -> None:
  183. settings = Settings.from_env()
  184. extractor = GeminiExtractor.from_env()
  185. linker = ScopeLinker()
  186. posts_out, payloads = [], []
  187. for src in SOURCES:
  188. cid = src["cid"]
  189. print(f"\n=== {src['platform']} {cid[:10]} ({src['from']}) ===")
  190. try:
  191. post = load_post(src, settings)
  192. read, is_empty, media, cards = read_one(src, post, settings, extractor)
  193. except Exception as exc:
  194. print(f" ✗ 取数/读懂失败:{exc}")
  195. posts_out.append({"post_id": cid, "source_id": cid, "title": f"(取数失败 {cid})",
  196. "platform": src["platform"], "url": "", "media": {"type": "image", "images": []},
  197. "cards": [], "error": str(exc)[:200], "knowledges": []})
  198. continue
  199. meta = {"post_id": cid, "source_id": post.id, "title": post.title or "",
  200. "platform": post.platform, "url": post.url, "media": media, "cards": cards}
  201. if is_empty: # ① 总闸
  202. print(f" ① 读懂 {len(read)} 字 → 判定无可提取的创作知识,跳过拆颗")
  203. posts_out.append({**meta, "no_knowledge": True, "knowledges": []})
  204. continue
  205. print(f" ① 读懂 {len(read)} 字({media['type']})")
  206. knowledges = shape(post, read)
  207. how_ids = {k.get("id") for k in knowledges if k.get("type") == "how"}
  208. how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
  209. for k in knowledges:
  210. k["业务阶段"] = [b for b in (k.get("业务阶段") or []) if b in ("灵感", "选题", "脚本")] # 守卫:只留合法业务阶段
  211. for s in k.get("steps", []): # 守卫:创作阶段只留合法 5 值,非法(如"定稿/输出")丢弃
  212. if s.get("创作阶段") not in CSTAGE:
  213. s["创作阶段"] = None
  214. if k.get("role") == "组件" and (k.get("parent") or {}).get("how_id") not in how_ids: # 守卫:组件 parent 必指向同帖 how
  215. k["role"] = "主"; k["parent"] = None
  216. print(f" ② {len(knowledges)} 颗:" + ", ".join(f"{k.get('type')}/{k.get('role')}" for k in knowledges))
  217. apply_scopes(knowledges, scope_candidates(knowledges), linker)
  218. print(" ③⑤ 作用域回扣 + 组装")
  219. posts_out.append({**meta, "knowledges": knowledges})
  220. payloads += [build_payload(post, k, how_titles) for k in knowledges]
  221. (ROOT / "web/frameworks.json").write_text(
  222. json.dumps({"count": len(posts_out), "posts": posts_out}, ensure_ascii=False, indent=1), encoding="utf-8")
  223. (ROOT / "web/payloads.json").write_text(
  224. json.dumps(payloads, ensure_ascii=False, indent=2), encoding="utf-8")
  225. print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads")
  226. if __name__ == "__main__":
  227. main()