decompose.py 31 KB

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