decompose.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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. import os
  13. import re
  14. from pathlib import Path
  15. from creation_knowledge.config import Settings
  16. from creation_knowledge.integrations import video_extract
  17. from creation_knowledge.integrations.crawler import fetch_post_detail, parse_detail_response
  18. from creation_knowledge.integrations.extractor import GeminiExtractor
  19. from creation_knowledge.integrations.llm import chat_json
  20. from creation_knowledge.prompts import load_prompt
  21. from scripts.scope_link import ScopeLinker
  22. ROOT = Path(__file__).resolve().parent.parent
  23. FIX = ROOT / "tests" / "fixtures"
  24. DATA = ROOT / "data" / "demo"
  25. SKILL = ROOT / "创作知识提取-skill"
  26. PHASE1 = (SKILL / "extraction" / "phase1-frame.md").read_text(encoding="utf-8")
  27. PHASE2 = (SKILL / "extraction" / "phase2-scope.md").read_text(encoding="utf-8")
  28. GATE_ADMIT = load_prompt("gate_admit") # ①.5 创作判定闸:判"是创作"
  29. GATE_REFUTE = load_prompt("gate_refute") # ①.5:挑刺"是制作/越界"
  30. GATE_TIEBREAK = load_prompt("gate_tiebreak") # ①.5:分歧裁决(边界倾向排除)
  31. NORMALIZE = load_prompt("normalize_scope") # ③前:作用域值名词化(③LLM)
  32. GATE_HOW_ADMIT = load_prompt("gate_how_admit") # ②.5 有序性闸:判"真流水线"
  33. GATE_HOW_REFUTE = load_prompt("gate_how_refute") # ②.5:挑"假how(离散构成硬串)"
  34. GATE_HOW_TIEBREAK = load_prompt("gate_how_tiebreak") # ②.5:分歧裁决(边界倾向假how)
  35. GATE_WHY_REFUTE = load_prompt("gate_why_refute") # ②.7 why 轻闸:单票 refute(废话/实为what/实为how)
  36. # ① 窄表确定性兜底:只放无歧义裸动作动词(绝不放 营造/引导/表达 等名词语素)
  37. _STRIP_VERBS = ("寻找", "定位", "推导", "核验", "提取", "挖掘", "捕捉",
  38. "识别", "梳理", "归纳", "判断", "验证", "确认", "复盘")
  39. _PROTECT = ("营造", "引导", "表达", "塑造", "叙述", "呈现", "刻画",
  40. "升华", "控制", "推进", "转化", "传达") # 名词语素,永不砍
  41. # from: fixture(读 tests/fixtures)/ live(实时 crawler 取数)
  42. SOURCES = [
  43. {"cid": "699308fa0000000016009697", "platform": "xiaohongshu", "from": "fixture"},
  44. {"cid": "698481e1000000000a02a7c1", "platform": "xiaohongshu", "from": "fixture"},
  45. {"cid": "67e2e39b0000000003028ff0", "platform": "xiaohongshu", "from": "fixture"},
  46. {"cid": "680659e8000000001a007a11", "platform": "xiaohongshu", "from": "fixture"},
  47. {"cid": "67e4bdf50000000006028a59", "platform": "xiaohongshu", "from": "fixture"},
  48. {"cid": "6901e15a0000000003019fa3", "platform": "xiaohongshu", "from": "live"}, # 新增1
  49. {"cid": "69ff58670000000023016fa2", "platform": "xiaohongshu", "from": "live"}, # 新增2
  50. {"cid": "7589257893544165455", "platform": "douyin", "from": "live"}, # 抖音视频(倒数第2)
  51. {"cid": "6a33655e000000000f0055af", "platform": "xiaohongshu", "from": "live"}, # 无知识 Voodoo(最后)
  52. ]
  53. SRC2CN = {"substance": "实质", "form": "形式", "feeling": "感受", "effect": "作用", "intent": "意图"}
  54. TYPE2ATTR = {"how": "how工序", "what": "what构成", "why": "why原理"}
  55. CSTAGE = {"定向", "构思", "结构", "成文", "打磨"} # 创作阶段受控 5 值
  56. KINDS = {"子集", "多维关系", "序列"} # what.kind 受控 3 值
  57. KIND_FIX = {"多维度关系": "多维关系", "多维": "多维关系", "集合": "子集", "序列型": "序列"} # LLM 偶发变体归一
  58. REUSE_THRESHOLD = 0.90
  59. # ---------- 取数 ----------
  60. def load_post(src: dict, settings: Settings):
  61. if src["from"] == "fixture":
  62. resp = json.loads((FIX / f"xhs_case_{src['cid']}.json").read_text("utf-8"))
  63. post = parse_detail_response(resp, fallback_content_id=src["cid"])
  64. else:
  65. post = fetch_post_detail(src["cid"], settings=settings)
  66. if not post.url:
  67. post.url = f"https://www.xiaohongshu.com/explore/{src['cid']}"
  68. return post
  69. # ---------- ① 读懂(图文/视频分流) ----------
  70. def _download_images(post) -> list[str]:
  71. """live 图文帖:把 CDN 图下载到本地 /data(避开小红书防盗链),返回本地公开路径;
  72. 单张失败则退回 CDN 直链(至少有 url)。复用 video_extract 的下载器(iOS UA + 平台 Referer)。"""
  73. base = DATA / "xiaohongshu" / post.id
  74. base.mkdir(parents=True, exist_ok=True)
  75. out = []
  76. for n, url in enumerate(post.image_urls, 1):
  77. dst = base / f"image_{n}.webp"
  78. pub = f"/data/demo/xiaohongshu/{post.id}/image_{n}.webp"
  79. try:
  80. if not dst.exists():
  81. dst.write_bytes(video_extract._default_download(url, post.platform))
  82. out.append(pub)
  83. except Exception:
  84. out.append(url)
  85. return out
  86. def read_one(src: dict, post, settings: Settings, extractor: GeminiExtractor):
  87. cid = src["cid"]
  88. if post.video_urls: # 视频帖:下载 mp4 + 原生整段提炼
  89. save = DATA / "douyin" / f"dy_{cid}" / "video.mp4"
  90. pub = f"/data/demo/douyin/dy_{cid}/video.mp4"
  91. ec = video_extract.extract_video(post, settings=settings, save_path=save, public_url=pub)
  92. media = {"type": "video", "video_url": pub, "images": []}
  93. cmap = {c.index: c.content for c in ec.cards}
  94. cards = [{"index": c.index, "content": cmap.get(c.index, ""), "video_url": pub,
  95. "start": c.start, "end": c.end} for c in post.cards] # 段卡:时间戳 + 读到的内容
  96. else: # 图文帖:读图
  97. ec = extractor.extract(post)
  98. if src["from"] == "fixture":
  99. imgs = [f"/data/demo/xiaohongshu/{post.id}/image_{n}.webp" for n in range(1, len(post.image_urls) + 1)]
  100. else:
  101. imgs = _download_images(post) # live 帖:下载 CDN 图到本地 /data,避开防盗链
  102. media = {"type": "image", "video_url": None, "images": imgs}
  103. cmap = {c.index: c.content for c in ec.cards}
  104. cards = [{"index": n, "content": cmap.get(n, ""), "image_url": imgs[n - 1] if n <= len(imgs) else None}
  105. for n in range(1, len(imgs) + 1)] # 每图一张卡:图 url + 读到的内容
  106. parts = [ec.text]
  107. if ec.from_image:
  108. parts.append("【图片要点】\n" + ec.from_image)
  109. parts += [f"【卡片{c.index}】{c.content}" for c in ec.cards if c.content]
  110. return "\n\n".join(p for p in parts if p), bool(ec.is_empty), media, cards
  111. # ---------- ② 判颗+成形+轻标签 ----------
  112. def shape(post, read: str) -> list[dict]:
  113. user = (f"原帖标题:{post.title or '(无)'}\n\n读懂后的完整内容:\n{read}\n\n"
  114. "按上面规则拆颗+判类型+成形+轻标签。作用域字段一律留空 []。"
  115. "只输出 JSON:{\"knowledges\":[ ... 见模板 ... ]}")
  116. return chat_json(PHASE1, user, timeout=120).get("knowledges") or []
  117. # ---------- ①.5 创作判定闸(voting:admit + refute,分歧上 tiebreak;边界倾向排除)----------
  118. def _vote(system: str, read: str, key: str, on_fail: bool) -> bool:
  119. """跑一次判定,取 key 字段为 bool;出错按 on_fail 兜底(避免 API 抖动误杀)。"""
  120. try:
  121. return bool(chat_json(system, read, timeout=90).get(key))
  122. except Exception:
  123. return on_fail
  124. def creation_gate(read: str) -> tuple[bool, str]:
  125. """判这帖是不是【图文/视频内容创作知识】。返回 (in_scope, 说明)。
  126. 甲方案:admit + refute 两票;一致即定;分歧→tiebreak 裁决(边界倾向排除)。
  127. 判定只看 ① 读懂后的内容,不看标题、不数关键词。"""
  128. v_admit = _vote(GATE_ADMIT, read, "in_scope", on_fail=True) # 判"是创作"
  129. v_refute = not _vote(GATE_REFUTE, read, "out_of_scope", on_fail=False) # 挑刺"是制作/越界"→归一成 in_scope
  130. if v_admit == v_refute:
  131. return v_admit, f"admit={v_admit}/refute={v_refute} 一致"
  132. v_tie = _vote(GATE_TIEBREAK, read, "in_scope", on_fail=False) # 分歧裁决,失败也倾向排除
  133. return v_tie, f"admit={v_admit}/refute={v_refute} 分歧→裁决={v_tie}"
  134. # ---------- ②.5 假how根治:有序性审查(层2信号+层3对抗投票)+ 假how重拆为 what/why ----------
  135. def _chain_signals(steps: list[dict]) -> list[str]:
  136. """层2 确定性信号:并列输入(input 不指向前步产出)/ 产出近义。喂给层3当证据。"""
  137. sigs, outs = [], [(s.get("output") or "") for s in steps]
  138. indep = 0
  139. for i, s in enumerate(steps):
  140. if i == 0:
  141. continue
  142. inp = s.get("input") or ""
  143. if not (("←" in inp) or any(o and o[:4] in inp for o in outs[:i])):
  144. indep += 1
  145. if indep >= 2:
  146. sigs.append(f"{indep} 个后步的 input 未指向前步产出(各自起头)")
  147. for i in range(len(outs)):
  148. for j in range(i + 1, len(outs)):
  149. a, b = outs[i], outs[j]
  150. if a and b and (a in b or b in a):
  151. sigs.append(f"步骤{i+1}与{j+1}产出近义({a} / {b})")
  152. break
  153. return sigs
  154. def how_gate(k: dict) -> tuple[bool, str]:
  155. """层3a 有序性闸(对抗投票:admit 判真链 / refute 挑假链 / 分歧裁决,边界倾向假how)。"""
  156. payload = json.dumps({
  157. "purpose": k.get("purpose"),
  158. "steps": [{"input": s.get("input"), "方法": (s.get("directive") or "")[:300], "产出": s.get("output")}
  159. for s in k.get("steps", [])],
  160. "代码信号": _chain_signals(k.get("steps", [])),
  161. }, ensure_ascii=False)
  162. v_admit = _vote(GATE_HOW_ADMIT, payload, "is_real_how", on_fail=True)
  163. v_refute = not _vote(GATE_HOW_REFUTE, payload, "is_fake", on_fail=False) # 不假 → 真
  164. if v_admit == v_refute:
  165. return v_admit, f"admit={v_admit}/refute={v_refute} 一致"
  166. v_tie = _vote(GATE_HOW_TIEBREAK, payload, "is_real_how", on_fail=False)
  167. return v_tie, f"admit={v_admit}/refute={v_refute} 分歧→裁决={v_tie}"
  168. def reshape_nonhow(k: dict) -> list[dict]:
  169. """层3b 假how重拆:只拆成 What/Why 主颗(不要 how/组件)。失败则保留原颗,不丢内容。"""
  170. body = f"目标:{k.get('purpose','')}\n" + "\n".join(
  171. f"- 输入:{s.get('input','')}|方法:{s.get('directive','')}|产出:{s.get('output','')}"
  172. for s in k.get("steps", []))
  173. user = ("【下面这块原被误判为 how 工序,实为「离散构成 / 原理」,请只拆成 What/Why 主颗——"
  174. "每个'是什么/分几类'的构成块拆一颗 What,背后的原理/标准拆一颗 Why;"
  175. "不要 how、不要组件颗,parent 一律 null。作用域字段留空 []。】\n\n"
  176. f"原标题:{k.get('title','')}\n{body}\n\n"
  177. "只输出 JSON:{\"knowledges\":[ ... 仅 what/why,见模板 ... ]}")
  178. try:
  179. out = chat_json(PHASE1, user, timeout=120).get("knowledges") or []
  180. except Exception:
  181. out = []
  182. res = []
  183. for i, nk in enumerate(out, 1):
  184. if nk.get("type") == "how": # 保险:拒绝又冒出来的 how
  185. continue
  186. nk["id"] = f"{k.get('id','k')}r{i}"
  187. nk["role"], nk["parent"] = "主", None
  188. res.append(nk)
  189. return res or [k] # 兜底:没拆出来就保留原颗
  190. def fix_fake_hows(knowledges: list[dict]) -> list[dict]:
  191. """逐颗 how 审查;假how → 重拆为 what/why(替换原颗)。"""
  192. out = []
  193. for k in knowledges:
  194. if k.get("type") == "how" and len(k.get("steps", [])) >= 2:
  195. real, why = how_gate(k)
  196. if not real:
  197. new = reshape_nonhow(k)
  198. print(f" ②.5 假how「{k.get('title')}」({why})→ 重拆 {len(new)} 颗")
  199. out.extend(new)
  200. continue
  201. out.append(k)
  202. return out
  203. # ---------- ②.7 why 轻闸:单票 refute(废话→drop;实为what/how→标 inferred 保留,不丢内容)----------
  204. def drop_fake_whys(knowledges: list[dict]) -> list[dict]:
  205. out = []
  206. for k in knowledges:
  207. if k.get("type") == "why":
  208. payload = json.dumps({"主张": k.get("主张"),
  209. "支撑": [{"小标题": b.get("小标题"), "内容": b.get("内容")}
  210. for b in k.get("支撑", [])]}, ensure_ascii=False)
  211. try:
  212. r = chat_json(GATE_WHY_REFUTE, payload, timeout=90)
  213. not_why, verdict, reason = bool(r.get("not_why")), r.get("verdict", ""), r.get("reason", "")
  214. except Exception:
  215. not_why, verdict, reason = False, "", "API错误→保留" # 兜底:保留,避免误杀
  216. if not_why:
  217. if verdict == "废话": # 正确的废话:无复用价值,直接 drop
  218. print(f" ②.7 why闸:drop 废话「{k.get('title')}」({reason})")
  219. continue
  220. if verdict in ("实为what", "实为how"): # 误分类:保留但标 inferred,待人工看,不丢内容
  221. k["inferred"] = True
  222. k["inferred_reason"] = f"why闸疑似{verdict}:{reason}"
  223. print(f" ②.7 why闸:标记「{k.get('title')}」({verdict}:{reason})")
  224. # 其它(not_why 与 verdict 自相矛盾/空)→ 保守保留、不扣帽子
  225. out.append(k)
  226. return out
  227. # ---------- ③ 作用域候选 + 定位 ----------
  228. def _slim(knowledges: list[dict]) -> list[dict]:
  229. slim = []
  230. for k in knowledges:
  231. e = {"id": k.get("id"), "type": k.get("type"), "title": k.get("title")}
  232. if k.get("type") == "how":
  233. e["steps"] = [{"id": s.get("id"), "input": s.get("input"),
  234. "directive": (s.get("directive") or "")[:500], "output": s.get("output")}
  235. for s in k.get("steps", [])]
  236. else:
  237. e["内容"] = {x: k.get(x) for x in ("概要", "维度拆分规则", "body", "主张", "支撑") if k.get(x)}
  238. slim.append(e)
  239. return slim
  240. def scope_candidates(knowledges: list[dict]) -> list:
  241. user = ("给下面每颗知识标作用域候选(how 逐步:每个 step 一组;what/why 颗级:整颗一组)。\n"
  242. "每类平铺列出所有相关值(同类可多条、全部对等、不选主次);先脑内摊正交、收敛近义;意图通常 1 个。\n"
  243. "只输出 JSON:{\"scopes\":[{\"knowledge_id\":\"k1\",\"step_id\":\"s1\",\"items\":["
  244. "{\"scope_type\":\"substance\",\"value\":\"实质值1\"},{\"scope_type\":\"substance\",\"value\":\"实质值2\"},"
  245. "{\"scope_type\":\"form\",\"value\":\"形式值1\"},{\"scope_type\":\"intent\",\"value\":\"意图值\"}]},"
  246. "{\"knowledge_id\":\"k2\",\"step_id\":null,\"items\":[...]}]}\n\n"
  247. + json.dumps(_slim(knowledges), ensure_ascii=False))
  248. return chat_json(PHASE2, user, timeout=120).get("scopes") or []
  249. def strip_verb_tail(v: str) -> str:
  250. """① 窄表确定性兜底:砍掉值开头/结尾的无歧义裸动词;砍到 <2 字则回退原值。"""
  251. if not v or len(v) < 3:
  252. return v
  253. for verb in _STRIP_VERBS: # 开头裸动词
  254. if v.startswith(verb) and len(v) - len(verb) >= 2:
  255. v = v[len(verb):]
  256. break
  257. for verb in _STRIP_VERBS: # 结尾裸动词(_PROTECT 与之不相交,名词语素天然不在表里)
  258. if v.endswith(verb) and len(v) - len(verb) >= 2:
  259. v = v[:-len(verb)]
  260. break
  261. return v
  262. def nounify_scopes(scopes: list) -> list:
  263. """作用域值名词化(仅 实质/形式/感受/作用):③ 批量 LLM 名词化 → ① 窄表兜底。在定位前做。
  264. 意图豁免:意图值就该是动词(对齐意图树),不名词化、不剥动词。"""
  265. vals = sorted({it["value"] for sc in scopes for it in (sc.get("items") or [])
  266. if it.get("value") and it.get("scope_type") != "intent"})
  267. mp = {}
  268. if vals:
  269. try:
  270. mp = chat_json(NORMALIZE, json.dumps(vals, ensure_ascii=False), timeout=90).get("映射") or {}
  271. except Exception:
  272. mp = {}
  273. for sc in scopes:
  274. for it in sc.get("items") or []:
  275. v = it.get("value")
  276. if not v or it.get("scope_type") == "intent": # 意图原样保留(动词)
  277. continue
  278. it["value"] = strip_verb_tail(mp.get(v) or v) # ③ 映射优先,再 ① 兜底
  279. return scopes
  280. def link_scope(linker: ScopeLinker, scope_type: str, value: str) -> dict:
  281. try:
  282. hits = linker.link(value, source_type=SRC2CN.get(scope_type, scope_type), top_k=3)
  283. except Exception:
  284. hits = []
  285. top = hits[0] if hits else {}
  286. score = float(top.get("score", 0.0))
  287. reuse = score >= REUSE_THRESHOLD and top.get("name")
  288. return {"scope_type": scope_type, "value": top["name"] if reuse else value,
  289. "candidate": value, "link": "复用" if reuse else "新建", "score": round(score, 4),
  290. "top": [{"name": h["name"], "score": h["score"], "path": h.get("path", "")} for h in hits]}
  291. _SCOPE_CONN = re.compile(r"\s*[和与、,,//&]\s*") # 作用域值确定性拆分:含连接词必拆成多原子
  292. def _split_scope_items(items: list) -> list:
  293. """连接词拆分守卫:把"社会共识与个体真实矛盾"这种拆成多原子,平铺。LLM 漏拆时兜底。"""
  294. out = []
  295. for it in (items or []):
  296. st, v = it.get("scope_type"), it.get("value")
  297. if not (st and v):
  298. continue
  299. parts = [p.strip() for p in _SCOPE_CONN.split(v) if p.strip()]
  300. for p in (parts if len(parts) > 1 else [v]):
  301. out.append({"scope_type": st, "value": p})
  302. return out
  303. def apply_scopes(knowledges: list[dict], scopes: list, linker: ScopeLinker) -> None:
  304. by_k = {k.get("id"): k for k in knowledges}
  305. for sc in scopes:
  306. k = by_k.get(sc.get("knowledge_id"))
  307. if not k:
  308. continue
  309. linked = [link_scope(linker, it["scope_type"], it["value"])
  310. for it in _split_scope_items(sc.get("items"))]
  311. if k.get("type") == "how" and sc.get("step_id"):
  312. for s in k.get("steps", []):
  313. if s.get("id") == sc["step_id"]:
  314. s["作用域"] = linked
  315. else:
  316. k["作用域"] = (k.get("作用域") or []) + linked
  317. # ---------- ⑤ 组装 ----------
  318. def build_content(k: dict):
  319. """How/Why → 拍平字符串;What → 结构化实体对象 {name,kind,维度拆分规则,body[]}。"""
  320. t = k.get("type")
  321. if t == "how":
  322. lines = [f"目标:{k.get('purpose','')}"]
  323. for i, s in enumerate(k.get("steps", []), 1):
  324. lines += [f"步骤{i}",
  325. f" 输入:{s.get('input','')}", f" 方法:{s.get('directive','')}", f" 产出:{s.get('output','')}"]
  326. return "\n".join(lines)
  327. if t == "what": # 结构化实体(非字符串);概要走 custom_ext,不进 content
  328. return {"name": k.get("title") or "", "kind": k.get("kind"),
  329. "维度拆分规则": k.get("维度拆分规则") if k.get("kind") == "子集" else None,
  330. "body": [{"item_name": it.get("item_name", ""), "item_desc": it.get("item_desc", ""),
  331. "作用域": it.get("作用域") or []} for it in (k.get("body") or [])]}
  332. return "\n".join([f"主张:{k.get('主张','')}"] + _sections(k.get("支撑")))
  333. def _sections(blocks) -> list[str]:
  334. """把 why.支撑 的自由小节拼成文本行(What 已改结构化 body,不再走这里)。"""
  335. out = []
  336. for b in blocks or []:
  337. head = b.get("小标题") or ""
  338. form = b.get("形式")
  339. out.append(f"【{head}】" + (f"({form})" if form else ""))
  340. if b.get("内容"):
  341. out.append(f" {b['内容']}")
  342. for it in b.get("条目") or []:
  343. word = it.get("词") or it.get("要素") or ""
  344. cue = it.get("选择线索")
  345. line = f" - {word}:{it.get('说明','')}" if word else f" - {it.get('说明','')}"
  346. if cue:
  347. line += f"(选用:{cue})"
  348. out.append(line)
  349. return out
  350. def build_payload(post, k: dict, how_titles: dict | None = None) -> dict:
  351. how_titles = how_titles or {}
  352. t = k.get("type")
  353. scopes, seen = [], set()
  354. def add(lst):
  355. for sc in lst:
  356. key = (sc["scope_type"], sc["value"])
  357. if key not in seen:
  358. seen.add(key); scopes.append({"scope_type": sc["scope_type"], "value": sc["value"]})
  359. if t == "how":
  360. for s in k.get("steps", []):
  361. add(s.get("作用域", []))
  362. else:
  363. add(k.get("作用域", []))
  364. ext = [{"key": "业务阶段", "type": "str", "value": v} for v in (k.get("业务阶段") or [])]
  365. if t == "what" and k.get("概要"): # What 的概要迁到 custom_ext(不再进 content)
  366. ext.append({"key": "概要", "type": "str", "value": k["概要"]})
  367. if t == "how":
  368. cs, cseen = [], set()
  369. for s in k.get("steps", []):
  370. c = s.get("创作阶段")
  371. if c and c not in cseen:
  372. cseen.add(c); cs.append(c)
  373. ext += [{"key": "创作阶段", "type": "str", "value": v} for v in cs]
  374. ext += [{"key": "动作", "type": "str", "value": s["动作"]} for s in k.get("steps", []) if s.get("动作")]
  375. if k.get("role") == "组件" and k.get("parent"):
  376. p = k["parent"]
  377. ext.append({"key": "出自", "type": "str",
  378. "value": f"{how_titles.get(p.get('how_id'), p.get('how_id'))} 第{p.get('step')}步"})
  379. return {"source": {"id": post.id, "source_type": "post", "title": post.title or "",
  380. "author": post.author_name or "", "source_metadata": {"platform": post.platform, "url": post.url}},
  381. "title": k.get("title"), "content": build_content(k),
  382. "dim_creations": ["创作"], "dim_attributes": [TYPE2ATTR.get(t, "how工序")],
  383. "scopes": scopes, "custom_ext": ext}
  384. def main() -> None:
  385. settings = Settings.from_env()
  386. extractor = GeminiExtractor.from_env()
  387. linker = ScopeLinker()
  388. posts_out, payloads = [], []
  389. for src in SOURCES:
  390. cid = src["cid"]
  391. print(f"\n=== {src['platform']} {cid[:10]} ({src['from']}) ===")
  392. try:
  393. post = load_post(src, settings)
  394. read, is_empty, media, cards = read_one(src, post, settings, extractor)
  395. except Exception as exc:
  396. print(f" ✗ 取数/读懂失败:{exc}")
  397. posts_out.append({"post_id": cid, "source_id": cid, "title": f"(取数失败 {cid})",
  398. "platform": src["platform"], "url": "", "media": {"type": "image", "images": []},
  399. "cards": [], "error": str(exc)[:200], "knowledges": []})
  400. continue
  401. meta = {"post_id": cid, "source_id": post.id, "title": post.title or "",
  402. "platform": post.platform, "url": post.url, "media": media, "cards": cards}
  403. if is_empty: # ① 总闸:纯展示/无可提取
  404. print(f" ① 读懂 {len(read)} 字 → ① 判纯展示/无知识,跳过")
  405. posts_out.append({**meta, "no_knowledge": True, "knowledges": []})
  406. continue
  407. in_scope, gate_why = creation_gate(read) # ①.5 创作判定闸(创作 vs 制作 vs 越界)
  408. if not in_scope:
  409. print(f" ① 读懂 {len(read)} 字 → ①.5 闸判【非创作】({gate_why})→ 整帖排除")
  410. posts_out.append({**meta, "no_knowledge": True, "knowledges": []})
  411. continue
  412. print(f" ① 读懂 {len(read)} 字({media['type']})· ①.5 闸:创作({gate_why})")
  413. knowledges = drop_fake_whys(fix_fake_hows(shape(post, read))) # ② 拆颗 → ②.5 假how根治 → ②.7 why轻闸
  414. how_ids = {k.get("id") for k in knowledges if k.get("type") == "how"}
  415. how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
  416. for k in knowledges:
  417. k["业务阶段"] = [b for b in (k.get("业务阶段") or []) if b in ("灵感", "选题", "脚本")] # 守卫:只留合法业务阶段
  418. if k.get("type") == "what" and k.get("kind") not in KINDS: # 守卫:what.kind 受控值,LLM 偶发变体归一
  419. k["kind"] = KIND_FIX.get((k.get("kind") or "").strip(), k.get("kind"))
  420. for s in k.get("steps", []): # 守卫:创作阶段只留合法 5 值,非法(如"定稿/输出")丢弃
  421. if s.get("创作阶段") not in CSTAGE:
  422. s["创作阶段"] = None
  423. if k.get("role") == "组件" and (k.get("parent") or {}).get("how_id") not in how_ids: # 守卫:组件 parent 必指向同帖 how
  424. k["role"] = "主"; k["parent"] = None
  425. print(f" ② {len(knowledges)} 颗:" + ", ".join(f"{k.get('type')}/{k.get('role')}" for k in knowledges))
  426. apply_scopes(knowledges, nounify_scopes(scope_candidates(knowledges)), linker) # ③名词化+①兜底 → 定位
  427. print(" ③⑤ 作用域定位 + 组装")
  428. posts_out.append({**meta, "knowledges": knowledges})
  429. payloads += [build_payload(post, k, how_titles) for k in knowledges]
  430. suffix = os.environ.get("OUT", "") # OUT=_v2 → 写 frameworks_v2.json,不覆盖原版
  431. (ROOT / f"web/frameworks{suffix}.json").write_text(
  432. json.dumps({"count": len(posts_out), "posts": posts_out}, ensure_ascii=False, indent=1), encoding="utf-8")
  433. (ROOT / f"web/payloads{suffix}.json").write_text(
  434. json.dumps(payloads, ensure_ascii=False, indent=2), encoding="utf-8")
  435. print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads → web/frameworks{suffix}.json")
  436. if __name__ == "__main__":
  437. main()