classify.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """帖子级「创作知识 / 非创作知识」分类(收紧版)+ 提取知识点。
  2. 判据已收紧到「**自媒体内容创作**」,并显式排除三类越界(见 prompts):① 应试/学术写作
  3. ② 制作/工具操作(=制作知识)③ 学科知识/评论/作品本身。两套真实提示词:
  4. 图文(小红书/微信):prompts/classify_imgtext.txt(标题+正文+全部图喂 Gemini)
  5. 视频(抖音):prompts/classify_video.txt(整段视频原生喂 Gemini,大视频先 ffmpeg 压 480p 保音轨)
  6. 都输出 {is_empty, reason, knowledge}:is_empty 即创作闸;is_empty=false 时连带提炼出具体创作知识点。
  7. 结果按 url 写 app.db 的 post_class(upsert 覆盖,可重判)。并发;与微信补下载并行安全(busy_timeout)。
  8. 只读 prompts、走 OpenRouter——不改 skill/creation_knowledge,不解构、不入 ingest。
  9. 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify [平台名...]
  10. """
  11. from __future__ import annotations
  12. import base64
  13. import concurrent.futures as cf
  14. import json
  15. import subprocess
  16. import sys
  17. import tempfile
  18. import time
  19. from pathlib import Path
  20. import httpx
  21. from acquisition import store
  22. from core.config import Settings
  23. from core.prompts import load_prompt
  24. ROOT = Path(__file__).resolve().parent.parent
  25. PLATFORMS = ["xiaohongshu", "weixin", "douyin"] # 默认重判全部;可传平台名覆盖
  26. IMG_WORKERS = 8 # 图文并发
  27. VID_WORKERS = 3 # 视频并发(含 ffmpeg 压制,别太高)
  28. MAX_CARDS = 12 # 图文最多送几张图
  29. COMPRESS_OVER_MB = 12 # 视频超过此大小先压再喂
  30. COMPRESS_H = 480
  31. def _data_url(public_path: str):
  32. fs = ROOT / public_path.lstrip("/")
  33. if not fs.exists():
  34. return None
  35. return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
  36. def _compress(mp4: Path) -> Path:
  37. """大视频压到 480p(保留口播音轨)→ 临时 mp4;失败回原文件。"""
  38. out = Path(tempfile.gettempdir()) / f"ck_{mp4.parent.name}_480.mp4"
  39. try:
  40. import imageio_ffmpeg
  41. ff = imageio_ffmpeg.get_ffmpeg_exe()
  42. subprocess.run([ff, "-y", "-i", str(mp4), "-vf", f"scale=-2:{COMPRESS_H}",
  43. "-c:v", "libx264", "-crf", "30", "-preset", "veryfast",
  44. "-c:a", "aac", "-b:a", "64k", str(out)],
  45. capture_output=True, timeout=300)
  46. if out.exists() and out.stat().st_size > 0:
  47. return out
  48. except Exception:
  49. pass
  50. return mp4
  51. def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
  52. """调 Gemini(OpenRouter,强制 JSON)解析 {is_empty, reason, knowledge},带重试。
  53. 返回 (is_creation 1/0/None, reason, knowledge, points)。"""
  54. api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
  55. headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
  56. payload = {"model": settings.video_model, "messages": messages,
  57. "response_format": {"type": "json_object"}}
  58. last = ""
  59. for attempt in range(3):
  60. try:
  61. resp = httpx.post(api, headers=headers, json=payload, timeout=timeout)
  62. if resp.status_code == 200:
  63. d = json.loads(resp.json()["choices"][0]["message"]["content"])
  64. if bool(d.get("is_empty")):
  65. return 0, str(d.get("reason", ""))[:60], "", ""
  66. return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
  67. last = f"http {resp.status_code}"
  68. except Exception as exc:
  69. last = str(exc)[:50]
  70. time.sleep(2 * (attempt + 1))
  71. return None, f"判定失败: {last}", "", ""
  72. def classify_imgtext(p: dict, settings: Settings) -> tuple:
  73. """图文:标题+正文+全部图,用收紧的 classify_imgtext.txt 判 is_empty 并提取知识点。"""
  74. user = [{"type": "text", "text": f"平台:{p.get('platform')}\n标题:{p.get('title', '')}\n"
  75. f"正文:{(p.get('body_text') or '')[:1500]}\n(下附帖子图片,请一并看完)"}]
  76. for im in (p.get("images") or [])[:MAX_CARDS]:
  77. u = _data_url(im)
  78. if u:
  79. user.append({"type": "image_url", "image_url": {"url": u}})
  80. messages = [{"role": "system", "content": load_prompt("classify_imgtext")},
  81. {"role": "user", "content": user}]
  82. return _judge(messages, settings, timeout=120)
  83. def classify_video(p: dict, settings: Settings) -> tuple:
  84. """视频:看完整段视频,用收紧的 classify_video.txt 判 is_empty 并提取知识点。"""
  85. rel = p.get("video") or ""
  86. mp4 = ROOT / rel.lstrip("/")
  87. if not rel or not mp4.exists():
  88. return None, "无本地视频", "", ""
  89. use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
  90. try:
  91. media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
  92. finally:
  93. if use != mp4:
  94. try:
  95. use.unlink()
  96. except Exception:
  97. pass
  98. messages = [{"role": "system", "content": load_prompt("classify_video")},
  99. {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
  100. {"type": "video_url", "video_url": {"url": media}}]}]
  101. return _judge(messages, settings, timeout=300)
  102. def _safe(fn, *a) -> tuple:
  103. try:
  104. return fn(*a)
  105. except Exception as exc:
  106. return None, f"判定失败: {str(exc)[:60]}", "", ""
  107. def main() -> None:
  108. settings = Settings.from_env()
  109. conn = store.connect()
  110. platforms = sys.argv[1:] or PLATFORMS
  111. posts = store.posts_to_classify(conn, platforms)
  112. imgs = [p for p in posts if p["platform"] != "douyin"]
  113. vids = [p for p in posts if p["platform"] == "douyin"]
  114. total = len(posts)
  115. print(f"收紧重判:图文 {len(imgs)}(并发{IMG_WORKERS})+ 抖音视频 {len(vids)}(并发{VID_WORKERS},大视频先压)")
  116. ts = int(time.time())
  117. done = {"n": 0, "fail": 0}
  118. def _write(p, res):
  119. ic, reason, knowledge, points = res
  120. if ic is None:
  121. done["fail"] += 1
  122. else:
  123. store.upsert_class(conn, p["url"], ic, reason, ts, knowledge, points)
  124. done["n"] += 1
  125. if done["n"] % 20 == 0:
  126. print(f" {done['n']}/{total}(失败 {done['fail']})")
  127. with cf.ThreadPoolExecutor(IMG_WORKERS) as ex:
  128. futs = {ex.submit(_safe, classify_imgtext, p, settings): p for p in imgs}
  129. for fut in cf.as_completed(futs):
  130. _write(futs[fut], fut.result())
  131. with cf.ThreadPoolExecutor(VID_WORKERS) as ex:
  132. futs = {ex.submit(_safe, classify_video, p, settings): p for p in vids}
  133. for fut in cf.as_completed(futs):
  134. _write(futs[fut], fut.result())
  135. c = store.class_counts(conn)
  136. conn.close()
  137. print(f"完成:创作知识 {c['creation']} / 非创作知识 {c['non_creation']}(本轮失败 {done['fail']},可重跑补判)")
  138. if __name__ == "__main__":
  139. main()