classify.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. """帖子级「创作知识 / 非创作知识」分类——忠实复用 pipeline 的「读懂 + 创作闸」,不简化提示词。
  2. 图文(小红书/微信):用 creation_knowledge 的 GeminiExtractor(**完整 prompts/extract.txt**,
  3. 带【卡片N】把全部图喂给 Gemini)→ ExtractedContent.is_empty 即创作闸(true=非创作)。
  4. 视频(抖音):用 extract_video(**完整 prompts/extract_video.txt**,整段视频原生喂 Gemini)→
  5. is_empty(=看完视频没提到任何创作知识段落)。大视频先 ffmpeg 压到 480p(保留口播音轨)再 base64。
  6. 结果按 url 写 app.db 的 post_class(upsert 覆盖,可重判)。并发;与微信补下载并行安全(busy_timeout)。
  7. 只读 prompts、import 调用 pipeline 函数——不改 skill/creation_knowledge,不解构、不入 ingest。
  8. 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify
  9. """
  10. from __future__ import annotations
  11. import base64
  12. import concurrent.futures as cf
  13. import json
  14. import subprocess
  15. import sys
  16. import tempfile
  17. import time
  18. from pathlib import Path
  19. import httpx
  20. from acquisition import store
  21. from core.config import Settings
  22. from core.models import Card, Post
  23. from core.prompts import load_prompt
  24. from creation_knowledge.integrations.extractor import GeminiExtractor
  25. ROOT = Path(__file__).resolve().parent.parent
  26. PLATFORMS = ["xiaohongshu", "douyin"] # 本轮重判平台(微信等正文下完再单独跑)
  27. IMG_WORKERS = 8 # 图文并发
  28. VID_WORKERS = 3 # 视频并发(含 ffmpeg 压制,别太高)
  29. MAX_CARDS = 12 # 图文最多送几张图(与 extractor 对齐)
  30. COMPRESS_OVER_MB = 12 # 视频超过此大小先压再喂
  31. COMPRESS_H = 480
  32. def _data_url(public_path: str):
  33. fs = ROOT / public_path.lstrip("/")
  34. if not fs.exists():
  35. return None
  36. return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
  37. def _imgtext_post(p: dict) -> Post:
  38. cards = []
  39. for i, im in enumerate((p.get("images") or [])[:MAX_CARDS], start=1):
  40. u = _data_url(im)
  41. if u:
  42. cards.append(Card(index=i, kind="image", url=u))
  43. return Post(id=f"{p['platform']}_cls", platform=p["platform"], url=p["url"],
  44. content_id=p["url"], title=p.get("title", ""),
  45. body_text=p.get("body_text", ""), cards=cards)
  46. def _compress(mp4: Path) -> Path:
  47. """大视频压到 480p(保留音轨——extract_video 要听口播)→ 临时 mp4;失败回原文件。"""
  48. out = Path(tempfile.gettempdir()) / f"ck_{mp4.parent.name}_480.mp4"
  49. try:
  50. import imageio_ffmpeg
  51. ff = imageio_ffmpeg.get_ffmpeg_exe()
  52. subprocess.run([ff, "-y", "-i", str(mp4), "-vf", f"scale=-2:{COMPRESS_H}",
  53. "-c:v", "libx264", "-crf", "30", "-preset", "veryfast",
  54. "-c:a", "aac", "-b:a", "64k", str(out)],
  55. capture_output=True, timeout=300)
  56. if out.exists() and out.stat().st_size > 0:
  57. return out
  58. except Exception:
  59. pass
  60. return mp4
  61. def classify_imgtext(p: dict, extractor: GeminiExtractor) -> tuple:
  62. ec = extractor.extract(_imgtext_post(p)) # 完整 extract.txt → is_empty + 提取的知识
  63. if ec.is_empty:
  64. return 0, "判为非创作:无可迁移的创作方法", "", ""
  65. points = json.dumps([{"index": c.index, "content": c.content} for c in (ec.cards or [])],
  66. ensure_ascii=False)
  67. return 1, (ec.text or "")[:60], (ec.text or ""), points # reason 取开头,knowledge 存全文
  68. def classify_video(p: dict, settings: Settings) -> tuple:
  69. """看完整段视频,用与图文同款的【严格创作知识判据】(classify_video.txt:两轴+越界+拦观点拔高)判 is_empty。
  70. 不再用 extract_video 的松「提炼」闸(它会把讲观点的视频拔高成创作知识)。"""
  71. rel = p.get("video") or ""
  72. mp4 = ROOT / rel.lstrip("/")
  73. if not rel or not mp4.exists():
  74. return None, "无本地视频", "", ""
  75. use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
  76. try:
  77. media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
  78. finally:
  79. if use != mp4:
  80. try:
  81. use.unlink()
  82. except Exception:
  83. pass
  84. messages = [{"role": "system", "content": load_prompt("classify_video")},
  85. {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
  86. {"type": "video_url", "video_url": {"url": media}}]}]
  87. api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
  88. headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
  89. payload = {"model": settings.video_model, "messages": messages,
  90. "response_format": {"type": "json_object"}}
  91. last = ""
  92. for attempt in range(3):
  93. try:
  94. resp = httpx.post(api, headers=headers, json=payload, timeout=300)
  95. if resp.status_code == 200:
  96. d = json.loads(resp.json()["choices"][0]["message"]["content"])
  97. is_empty = bool(d.get("is_empty"))
  98. if is_empty:
  99. return 0, str(d.get("reason", ""))[:60], "", ""
  100. kn = str(d.get("knowledge", "") or "")
  101. return 1, str(d.get("reason", ""))[:60], kn, ""
  102. last = f"http {resp.status_code}"
  103. except Exception as exc:
  104. last = str(exc)[:50]
  105. time.sleep(2 * (attempt + 1))
  106. return None, f"判定失败: {last}", "", ""
  107. def _safe(fn, *a) -> tuple:
  108. try:
  109. return fn(*a)
  110. except Exception as exc:
  111. return None, f"判定失败: {str(exc)[:60]}", "", ""
  112. def main() -> None:
  113. settings = Settings.from_env()
  114. extractor = GeminiExtractor.from_env()
  115. conn = store.connect()
  116. platforms = sys.argv[1:] or PLATFORMS # 可传平台名重判,如:... -m acquisition.classify weixin
  117. posts = store.posts_to_classify(conn, platforms)
  118. imgs = [p for p in posts if p["platform"] != "douyin"]
  119. vids = [p for p in posts if p["platform"] == "douyin"]
  120. total = len(posts)
  121. print(f"忠实重判:图文 {len(imgs)}(并发{IMG_WORKERS})+ 抖音视频 {len(vids)}(并发{VID_WORKERS},大视频先压)")
  122. ts = int(time.time())
  123. done = {"n": 0, "fail": 0}
  124. def _write(p, res):
  125. ic, reason, knowledge, points = res
  126. if ic is None:
  127. done["fail"] += 1
  128. else:
  129. store.upsert_class(conn, p["url"], ic, reason, ts, knowledge, points)
  130. done["n"] += 1
  131. if done["n"] % 20 == 0:
  132. print(f" {done['n']}/{total}(失败 {done['fail']})")
  133. with cf.ThreadPoolExecutor(IMG_WORKERS) as ex:
  134. futs = {ex.submit(_safe, classify_imgtext, p, extractor): p for p in imgs}
  135. for fut in cf.as_completed(futs):
  136. _write(futs[fut], fut.result())
  137. with cf.ThreadPoolExecutor(VID_WORKERS) as ex:
  138. futs = {ex.submit(_safe, classify_video, p, settings): p for p in vids}
  139. for fut in cf.as_completed(futs):
  140. _write(futs[fut], fut.result())
  141. c = store.class_counts(conn)
  142. conn.close()
  143. print(f"完成(含历史微信):创作知识 {c['creation']} / 非创作知识 {c['non_creation']}"
  144. f"(本轮失败 {done['fail']},可重跑补判)")
  145. if __name__ == "__main__":
  146. main()