decompose.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. """创作知识解构引擎 v2:一帖 → N 颗(how/what/why,含组件颗)→ frameworks.json + payloads.json。
  2. 编排全流程,把 skill 的 phase 文档当 prompt 喂给 LLM(skill 是唯一真源):
  3. ① 读懂:extractor 读图 → 完整文字(vision, OpenRouter)
  4. ② 判颗+类型闸+三lane成形+轻标签:system = phase1-frame.md
  5. ③ 作用域:system = phase2-scope.md → 候选 → scope_link 回扣(火山)
  6. ⑤ 组装:代码 → 每颗一个 ingest payload(按类型分拼)
  7. 须在云端跑(OpenRouter vision + 火山 可达)。用法:PYTHONPATH=. python scripts/decompose.py
  8. """
  9. from __future__ import annotations
  10. import json
  11. from pathlib import Path
  12. from creation_knowledge.integrations.crawler import parse_detail_response
  13. from creation_knowledge.integrations.extractor import GeminiExtractor
  14. from creation_knowledge.integrations.llm import chat_json
  15. from scripts.scope_link import ScopeLinker
  16. ROOT = Path(__file__).resolve().parent.parent
  17. FIX = ROOT / "tests" / "fixtures"
  18. SKILL = ROOT / "创作知识提取-skill"
  19. PHASE1 = (SKILL / "extraction" / "phase1-frame.md").read_text(encoding="utf-8")
  20. PHASE2 = (SKILL / "extraction" / "phase2-scope.md").read_text(encoding="utf-8")
  21. CIDS = ["699308fa0000000016009697", "698481e1000000000a02a7c1",
  22. "67e2e39b0000000003028ff0", "680659e8000000001a007a11",
  23. "67e4bdf50000000006028a59"]
  24. SRC2CN = {"substance": "实质", "form": "形式", "feeling": "感受", "effect": "作用", "intent": "意图"}
  25. TYPE2ATTR = {"how": "how工序", "what": "what构成", "why": "why原理"}
  26. REUSE_THRESHOLD = 0.90
  27. # ---------- ① 读懂 ----------
  28. def read_post(cid: str, extractor: GeminiExtractor):
  29. resp = json.loads((FIX / f"xhs_case_{cid}.json").read_text("utf-8"))
  30. post = parse_detail_response(resp, fallback_content_id=cid)
  31. if not post.url:
  32. post.url = f"https://www.xiaohongshu.com/explore/{cid}"
  33. ec = extractor.extract(post)
  34. parts = [ec.text]
  35. if ec.from_image:
  36. parts.append("【图片要点】\n" + ec.from_image)
  37. parts += [f"【卡片{c.index}】{c.content}" for c in ec.cards if c.content]
  38. return post, "\n\n".join(p for p in parts if p)
  39. # ---------- ② 判颗+成形+轻标签 ----------
  40. def shape(post, read: str) -> list[dict]:
  41. user = (f"原帖标题:{post.title or '(无)'}\n\n读懂后的完整内容:\n{read}\n\n"
  42. "按上面规则拆颗+判类型+成形+轻标签。作用域字段一律留空 []。"
  43. "只输出 JSON:{\"knowledges\":[ ... 见模板 ... ]}")
  44. out = chat_json(PHASE1, user, timeout=120)
  45. return out.get("knowledges") or []
  46. # ---------- ③ 作用域候选 + 回扣 ----------
  47. def _slim(knowledges: list[dict]) -> list[dict]:
  48. slim = []
  49. for k in knowledges:
  50. e = {"id": k.get("id"), "type": k.get("type"), "title": k.get("title")}
  51. if k.get("type") == "how":
  52. e["steps"] = [{"id": s.get("id"), "intent": s.get("intent"),
  53. "directive": (s.get("directive") or "")[:500], "output": s.get("output")}
  54. for s in k.get("steps", [])]
  55. else:
  56. e["内容"] = {x: k.get(x) for x in ("界定", "构成", "主张", "依据", "对创作的影响") if k.get(x)}
  57. slim.append(e)
  58. return slim
  59. def scope_candidates(knowledges: list[dict]) -> dict:
  60. user = ("给下面每颗知识标作用域候选(how 逐步:每个 step 一组;what/why 颗级:整颗一组)。\n"
  61. "只输出 JSON:{\"scopes\":[{\"knowledge_id\":\"k1\",\"step_id\":\"s1\",\"items\":[{\"scope_type\":\"substance\",\"value\":\"赛道共识\"}]},"
  62. "{\"knowledge_id\":\"k2\",\"step_id\":null,\"items\":[...]}]}\n\n"
  63. + json.dumps(_slim(knowledges), ensure_ascii=False))
  64. out = chat_json(PHASE2, user, timeout=120)
  65. return out.get("scopes") or []
  66. def link_scope(linker: ScopeLinker, scope_type: str, value: str) -> dict:
  67. try:
  68. hits = linker.link(value, source_type=SRC2CN.get(scope_type, scope_type), top_k=3)
  69. except Exception:
  70. hits = []
  71. top = hits[0] if hits else {}
  72. score = float(top.get("score", 0.0))
  73. reuse = score >= REUSE_THRESHOLD and top.get("name")
  74. return {"scope_type": scope_type, "value": top["name"] if reuse else value,
  75. "candidate": value, "link": "复用" if reuse else "新建", "score": round(score, 4),
  76. "top": [{"name": h["name"], "score": h["score"], "path": h.get("path", "")} for h in hits]}
  77. def apply_scopes(knowledges: list[dict], scopes: list[dict], linker: ScopeLinker) -> None:
  78. by_k = {k.get("id"): k for k in knowledges}
  79. for sc in scopes:
  80. k = by_k.get(sc.get("knowledge_id"))
  81. if not k:
  82. continue
  83. linked = [link_scope(linker, it["scope_type"], it["value"])
  84. for it in (sc.get("items") or []) if it.get("scope_type") and it.get("value")]
  85. if k.get("type") == "how" and sc.get("step_id"):
  86. for s in k.get("steps", []):
  87. if s.get("id") == sc["step_id"]:
  88. s["作用域"] = linked
  89. else:
  90. k["作用域"] = (k.get("作用域") or []) + linked
  91. # ---------- ⑤ 组装 payload ----------
  92. def build_content(k: dict) -> str:
  93. t = k.get("type")
  94. if t == "how":
  95. lines = [f"目标:{k.get('purpose','')}"]
  96. for i, s in enumerate(k.get("steps", []), 1):
  97. lines += [f"步骤{i}(目的:{s.get('intent','')})",
  98. f" 指引:{s.get('directive','')}", f" 产出:{s.get('output','')}"]
  99. return "\n".join(lines)
  100. if t == "what":
  101. lines = [f"界定:{k.get('界定','')}", "构成:"]
  102. lines += [f"- {c.get('要素','')}:{c.get('说明','')}" for c in k.get("构成", [])]
  103. return "\n".join(lines)
  104. return f"主张:{k.get('主张','')}\n依据:{k.get('依据','')}\n对创作的影响:{k.get('对创作的影响','')}"
  105. def build_payload(post, k: dict, how_titles: dict | None = None) -> dict:
  106. how_titles = how_titles or {}
  107. t = k.get("type")
  108. scopes, seen = [], set()
  109. def add(lst):
  110. for sc in lst:
  111. key = (sc["scope_type"], sc["value"])
  112. if key not in seen:
  113. seen.add(key); scopes.append({"scope_type": sc["scope_type"], "value": sc["value"]})
  114. if t == "how":
  115. for s in k.get("steps", []):
  116. add(s.get("作用域", []))
  117. else:
  118. add(k.get("作用域", []))
  119. ext = [{"key": "业务阶段", "type": "str", "value": v} for v in (k.get("业务阶段") or [])]
  120. if t == "how":
  121. cs, cseen = [], set()
  122. for s in k.get("steps", []):
  123. c = s.get("创作阶段")
  124. if c and c not in cseen:
  125. cseen.add(c); cs.append(c)
  126. ext += [{"key": "创作阶段", "type": "str", "value": v} for v in cs]
  127. ext += [{"key": "动作", "type": "str", "value": s["动作"]} for s in k.get("steps", []) if s.get("动作")]
  128. if k.get("role") == "组件" and k.get("parent"):
  129. p = k["parent"]
  130. ext.append({"key": "出自", "type": "str",
  131. "value": f"{how_titles.get(p.get('how_id'), p.get('how_id'))} 第{p.get('step')}步"})
  132. return {"source": {"id": f"xhs_{post.content_id}", "source_type": "post", "title": post.title or "",
  133. "author": post.author_name or "", "source_metadata": {"platform": post.platform, "url": post.url}},
  134. "title": k.get("title"), "content": build_content(k),
  135. "dim_creations": ["创作"], "dim_attributes": [TYPE2ATTR.get(t, "how工序")],
  136. "scopes": scopes, "custom_ext": ext}
  137. def main() -> None:
  138. extractor = GeminiExtractor.from_env()
  139. linker = ScopeLinker()
  140. posts_out, payloads = [], []
  141. for cid in CIDS:
  142. print(f"\n=== {cid[:8]} ===")
  143. post, read = read_post(cid, extractor)
  144. print(f" ① 读懂 {len(read)} 字")
  145. knowledges = shape(post, read)
  146. # 守卫:组件颗 parent 必须指向同帖一个 how,否则降级为 orphan 主颗
  147. how_ids = {k.get("id") for k in knowledges if k.get("type") == "how"}
  148. how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
  149. for k in knowledges:
  150. if k.get("role") == "组件" and (k.get("parent") or {}).get("how_id") not in how_ids:
  151. k["role"] = "主"
  152. k["parent"] = None
  153. print(f" ② {len(knowledges)} 颗:" + ", ".join(f"{k.get('type')}/{k.get('role')}" for k in knowledges))
  154. apply_scopes(knowledges, scope_candidates(knowledges), linker)
  155. print(" ③⑤ 作用域回扣 + 组装")
  156. posts_out.append({"post_id": cid, "title": post.title or "", "platform": post.platform,
  157. "url": post.url, "img_base": f"/data/demo/xiaohongshu/xhs_{cid}",
  158. "img_count": len(post.image_urls), "knowledges": knowledges})
  159. payloads += [build_payload(post, k, how_titles) for k in knowledges]
  160. (ROOT / "web/frameworks.json").write_text(
  161. json.dumps({"count": len(posts_out), "posts": posts_out}, ensure_ascii=False, indent=1), encoding="utf-8")
  162. (ROOT / "web/payloads.json").write_text(
  163. json.dumps(payloads, ensure_ascii=False, indent=2), encoding="utf-8")
  164. print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads")
  165. if __name__ == "__main__":
  166. main()