classify.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. """帖子级「创作知识 / 非创作知识」分类 + 提取知识点。
  2. 判据是「内容作品创作知识」,覆盖图文、视频、脚本、游戏视频、历史视频等内容创作场景,
  3. 并显式排除三类越界(见 prompts):① 应试/学术写作
  4. ② 制作/工具操作(=制作知识)③ 学科知识/评论/作品本身。两套真实提示词:
  5. 图文(小红书/微信):prompts/classify_imgtext.txt(标题+正文+全部图喂多模态模型)
  6. 视频(抖音):prompts/classify_video.txt(OSS/CDN URL 直喂;本地大视频 fallback 时先压 480p 保音轨)
  7. 都输出 {is_empty, reason, knowledge}:is_empty 即创作闸;is_empty=false 时连带提炼出具体创作知识点。
  8. 结果按 url 写 app.db 的 post_class(upsert 覆盖,可重判)。并发;与微信补下载并行安全(busy_timeout)。
  9. 只读 prompts、走 OpenRouter / Ark / Qwen——不改 skill/creation_knowledge,不解构、不入 ingest。
  10. 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify [平台名...]
  11. """
  12. from __future__ import annotations
  13. import base64
  14. import concurrent.futures as cf
  15. import json
  16. import os
  17. import subprocess
  18. import sys
  19. import tempfile
  20. import threading
  21. import time
  22. from pathlib import Path
  23. from urllib.parse import urlparse
  24. import httpx
  25. from acquisition import store
  26. from core.config import Settings, load_env_file
  27. from core.prompts import load_prompt
  28. ROOT = Path(__file__).resolve().parent.parent
  29. PLATFORMS = ["xiaohongshu", "weixin", "douyin"] # 默认重判全部;可传平台名覆盖
  30. IMG_WORKERS = 8 # 图文并发
  31. VID_WORKERS = 3 # 视频并发(含 ffmpeg 压制,别太高)
  32. MAX_CARDS = 12 # 图文最多送几张图
  33. COMPRESS_OVER_MB = 12 # 视频超过此大小先压再喂
  34. COMPRESS_H = 480
  35. DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS = 0.5
  36. DEFAULT_429_BACKOFF_SECONDS = [30.0, 90.0, 180.0]
  37. RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
  38. class _ProviderThrottle:
  39. def __init__(self) -> None:
  40. self._lock = threading.Lock()
  41. self._next_allowed = 0.0
  42. def wait(self, min_interval_seconds: float) -> None:
  43. with self._lock:
  44. now = time.monotonic()
  45. sleep_for = max(0.0, self._next_allowed - now)
  46. self._next_allowed = max(now, self._next_allowed) + min_interval_seconds
  47. if sleep_for > 0:
  48. time.sleep(sleep_for)
  49. def penalize(self, seconds: float) -> None:
  50. if seconds <= 0:
  51. return
  52. with self._lock:
  53. self._next_allowed = max(self._next_allowed, time.monotonic() + seconds)
  54. _PROVIDER_THROTTLES: dict[str, _ProviderThrottle] = {}
  55. _PROVIDER_THROTTLES_LOCK = threading.Lock()
  56. def _data_url(public_path: str):
  57. fs = ROOT / public_path.lstrip("/")
  58. if not fs.exists():
  59. return None
  60. return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
  61. def _is_http_url(value: str) -> bool:
  62. try:
  63. parsed = urlparse(value)
  64. except Exception:
  65. return False
  66. return parsed.scheme in ("http", "https") and bool(parsed.netloc)
  67. def _compress(mp4: Path) -> Path:
  68. """大视频压到 480p(保留口播音轨)→ 临时 mp4;失败回原文件。"""
  69. out = Path(tempfile.gettempdir()) / f"ck_{mp4.parent.name}_480.mp4"
  70. try:
  71. import imageio_ffmpeg
  72. ff = imageio_ffmpeg.get_ffmpeg_exe()
  73. subprocess.run([ff, "-y", "-i", str(mp4), "-vf", f"scale=-2:{COMPRESS_H}",
  74. "-c:v", "libx264", "-crf", "30", "-preset", "veryfast",
  75. "-c:a", "aac", "-b:a", "64k", str(out)],
  76. capture_output=True, timeout=300)
  77. if out.exists() and out.stat().st_size > 0:
  78. return out
  79. except Exception:
  80. pass
  81. return mp4
  82. def _parse_judge_content(content: str) -> tuple:
  83. d = json.loads(content)
  84. if bool(d.get("is_empty")):
  85. return 0, str(d.get("reason", ""))[:60], "", ""
  86. return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
  87. def _env_first(env: dict, *keys: str) -> str:
  88. for key in keys:
  89. value = os.getenv(key) or env.get(key)
  90. if value:
  91. return value
  92. return ""
  93. def _is_qwen_model(model: str) -> bool:
  94. lower = model.lower()
  95. return lower.startswith("qwen") or lower.startswith("qwq")
  96. def _provider_key(name: str) -> str:
  97. return name.split(":", 1)[0].lower()
  98. def _provider_throttle(provider: str) -> _ProviderThrottle:
  99. with _PROVIDER_THROTTLES_LOCK:
  100. throttle = _PROVIDER_THROTTLES.get(provider)
  101. if throttle is None:
  102. throttle = _ProviderThrottle()
  103. _PROVIDER_THROTTLES[provider] = throttle
  104. return throttle
  105. def _env_float(env: dict, default: float, *keys: str) -> float:
  106. value = _env_first(env, *keys)
  107. if not value:
  108. return default
  109. try:
  110. return max(0.0, float(value))
  111. except ValueError:
  112. return default
  113. def _provider_min_interval(provider: str, env: dict) -> float:
  114. prefix = provider.upper()
  115. return _env_float(
  116. env,
  117. DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS,
  118. f"CLASSIFY_{prefix}_MIN_INTERVAL_SECONDS",
  119. "CLASSIFY_PROVIDER_MIN_INTERVAL_SECONDS",
  120. )
  121. def _parse_backoff_list(value: str) -> list[float]:
  122. out = []
  123. for part in value.split(","):
  124. try:
  125. seconds = float(part.strip())
  126. except ValueError:
  127. continue
  128. if seconds > 0:
  129. out.append(seconds)
  130. return out or DEFAULT_429_BACKOFF_SECONDS
  131. def _retry_after_seconds(resp: httpx.Response) -> float | None:
  132. value = resp.headers.get("retry-after")
  133. if not value:
  134. return None
  135. try:
  136. return max(0.0, float(value))
  137. except ValueError:
  138. return None
  139. def _provider_429_backoff(provider: str, env: dict, attempt: int, resp: httpx.Response) -> float:
  140. retry_after = _retry_after_seconds(resp)
  141. if retry_after is not None:
  142. return retry_after
  143. prefix = provider.upper()
  144. values = _parse_backoff_list(
  145. _env_first(
  146. env,
  147. f"CLASSIFY_{prefix}_429_BACKOFF_SECONDS",
  148. "CLASSIFY_429_BACKOFF_SECONDS",
  149. ) or ",".join(str(v) for v in DEFAULT_429_BACKOFF_SECONDS)
  150. )
  151. return values[min(attempt, len(values) - 1)]
  152. def _providers(settings: Settings, messages: list) -> list[tuple[str, str, dict, dict]]:
  153. body = {"model": settings.video_model, "messages": messages,
  154. "response_format": {"type": "json_object"}}
  155. env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
  156. out: list[tuple[str, str, dict, dict]] = []
  157. provider = (_env_first(env, "CLASSIFY_PROVIDER") or "auto").lower()
  158. explicit_model = _env_first(env, "CLASSIFY_MODEL")
  159. bailian_key = _env_first(
  160. env,
  161. "ALIYUN_BAILIAN_API_KEY",
  162. )
  163. if bailian_key and provider in ("auto", "qwen", "bailian"):
  164. bailian_url = _env_first(
  165. env,
  166. "ALIYUN_BAILIAN_BASE_URL",
  167. ) or "https://dashscope.aliyuncs.com/compatible-mode/v1"
  168. models = []
  169. for model in [
  170. explicit_model,
  171. _env_first(env, "ALIYUN_BAILIAN_MODEL"),
  172. _env_first(env, "OPENROUTER_MODEL"),
  173. "qwen3.7-plus",
  174. "qwen-vl-plus",
  175. ]:
  176. if model and _is_qwen_model(model) and model not in models:
  177. models.append(model)
  178. for model in models:
  179. out.append((
  180. f"qwen:{model}",
  181. bailian_url.rstrip("/") + "/chat/completions",
  182. {"Authorization": f"Bearer {bailian_key}", "Content-Type": "application/json"},
  183. {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
  184. ))
  185. if provider in ("qwen", "bailian"):
  186. return out
  187. if settings.openrouter_api_key and provider in ("auto", "openrouter"):
  188. out.append((
  189. "openrouter",
  190. settings.openrouter_base_url.rstrip("/") + "/chat/completions",
  191. {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"},
  192. body,
  193. ))
  194. ark_key = _env_first(env, "ARK_API_KEY")
  195. if ark_key and provider in ("auto", "ark"):
  196. ark_url = _env_first(env, "ARK_CHAT_URL") or "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
  197. models = []
  198. explicit = explicit_model or _env_first(env, "ARK_CHAT_MODEL")
  199. if explicit:
  200. models.append(explicit)
  201. # Seed 2 Mini 接入点/1.6 Vision 更适合图文+视频理解;flash 作为轻量兜底。
  202. models.extend(["ep-20260506151915-jqvw7", "doubao-seed-1-6-vision-250815", "doubao-seed-1-6-flash-250615"])
  203. seen = set()
  204. for model in models:
  205. if model in seen:
  206. continue
  207. seen.add(model)
  208. out.append((
  209. f"ark:{model}",
  210. ark_url,
  211. {"Authorization": f"Bearer {ark_key}", "Content-Type": "application/json"},
  212. {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
  213. ))
  214. return out
  215. def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
  216. """调多模态模型(Qwen / OpenRouter / Ark),解析 {is_empty, reason, knowledge},带重试。
  217. 返回 (is_creation 1/0/None, reason, knowledge, points)。"""
  218. last = ""
  219. env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
  220. for name, api, headers, payload in _providers(settings, messages):
  221. provider = _provider_key(name)
  222. throttle = _provider_throttle(provider)
  223. min_interval = _provider_min_interval(provider, env)
  224. for attempt in range(3):
  225. try:
  226. throttle.wait(min_interval)
  227. resp = httpx.post(api, headers=headers, json=payload, timeout=timeout)
  228. if resp.status_code == 200:
  229. return _parse_judge_content(resp.json()["choices"][0]["message"]["content"])
  230. last = f"{name} http {resp.status_code}"
  231. if resp.status_code == 429:
  232. throttle.penalize(_provider_429_backoff(provider, env, attempt, resp))
  233. if resp.status_code in (401, 403, 404):
  234. break
  235. except Exception as exc:
  236. last = f"{name} {str(exc)[:50]}"
  237. if "http " not in last or any(f"http {code}" in last for code in RETRYABLE_STATUS_CODES):
  238. time.sleep(2 * (attempt + 1))
  239. else:
  240. break
  241. if not last:
  242. last = "missing Qwen/OpenRouter/Ark credentials"
  243. return None, f"判定失败: {last}", "", ""
  244. def classify_imgtext(p: dict, settings: Settings) -> tuple:
  245. """图文:标题+正文+全部图,用收紧的 classify_imgtext.txt 判 is_empty 并提取知识点。"""
  246. user = [{"type": "text", "text": f"平台:{p.get('platform')}\n标题:{p.get('title', '')}\n"
  247. f"正文:{(p.get('body_text') or '')[:1500]}\n(下附帖子图片,请一并看完)"}]
  248. for im in (p.get("images") or [])[:MAX_CARDS]:
  249. u = _data_url(im)
  250. if u:
  251. user.append({"type": "image_url", "image_url": {"url": u}})
  252. messages = [{"role": "system", "content": load_prompt("classify_imgtext")},
  253. {"role": "user", "content": user}]
  254. return _judge(messages, settings, timeout=120)
  255. def classify_video(p: dict, settings: Settings) -> tuple:
  256. """视频:看完整段视频,用 classify_video.txt 判 is_empty 并提取知识点。
  257. 新采集链路里抖音视频会先转存 OSS,传入 HTTP(S) CDN URL;老链路传 /data 本地 mp4。
  258. """
  259. rel = p.get("video") or ""
  260. if _is_http_url(rel):
  261. media = rel
  262. else:
  263. mp4 = ROOT / rel.lstrip("/")
  264. if not rel or not mp4.exists():
  265. return None, "无视频", "", ""
  266. use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
  267. try:
  268. media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
  269. finally:
  270. if use != mp4:
  271. try:
  272. use.unlink()
  273. except Exception:
  274. pass
  275. messages = [{"role": "system", "content": load_prompt("classify_video")},
  276. {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
  277. {"type": "video_url", "video_url": {"url": media}}]}]
  278. return _judge(messages, settings, timeout=300)
  279. def _safe(fn, *a) -> tuple:
  280. try:
  281. return fn(*a)
  282. except Exception as exc:
  283. return None, f"判定失败: {str(exc)[:60]}", "", ""
  284. def main() -> None:
  285. settings = Settings.from_env()
  286. conn = store.connect()
  287. platforms = sys.argv[1:] or PLATFORMS
  288. posts = store.posts_to_classify(conn, platforms)
  289. imgs = [p for p in posts if p["platform"] != "douyin"]
  290. vids = [p for p in posts if p["platform"] == "douyin"]
  291. total = len(posts)
  292. print(f"收紧重判:图文 {len(imgs)}(并发{IMG_WORKERS})+ 抖音视频 {len(vids)}(并发{VID_WORKERS},大视频先压)")
  293. ts = int(time.time())
  294. done = {"n": 0, "fail": 0}
  295. def _write(p, res):
  296. ic, reason, knowledge, points = res
  297. if ic is None:
  298. done["fail"] += 1
  299. else:
  300. store.upsert_class(conn, p["url"], ic, reason, ts, knowledge, points)
  301. done["n"] += 1
  302. if done["n"] % 20 == 0:
  303. print(f" {done['n']}/{total}(失败 {done['fail']})")
  304. with cf.ThreadPoolExecutor(IMG_WORKERS) as ex:
  305. futs = {ex.submit(_safe, classify_imgtext, p, settings): p for p in imgs}
  306. for fut in cf.as_completed(futs):
  307. _write(futs[fut], fut.result())
  308. with cf.ThreadPoolExecutor(VID_WORKERS) as ex:
  309. futs = {ex.submit(_safe, classify_video, p, settings): p for p in vids}
  310. for fut in cf.as_completed(futs):
  311. _write(futs[fut], fut.result())
  312. c = store.class_counts(conn)
  313. conn.close()
  314. print(f"完成:创作知识 {c['creation']} / 非创作知识 {c['non_creation']}(本轮失败 {done['fail']},可重跑补判)")
  315. if __name__ == "__main__":
  316. main()