classify.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. """把已下载的帖子分成「创作知识 / 非创作知识」(只判定,不解构、不提知识点)。
  2. 复用 prompts/extract.txt 里的「创作知识」定义(只读,绝不改 skill/creation_knowledge)。
  3. 方案 2:标题 + 正文 + 封面/帖内图一起喂 Gemini(OpenRouter 多模态),并发 10 + 失败重试。
  4. 结果写 data/app.db 的 post_class 表(按 url),前端按 url 显示「创作知识 / 非创作知识」角标。
  5. 不碰视频/帖子文件本身,不入 ingest。重跑只补未分类的(幂等)。
  6. 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify
  7. """
  8. from __future__ import annotations
  9. import base64
  10. import concurrent.futures as cf
  11. import json
  12. import time
  13. from pathlib import Path
  14. import httpx
  15. from acquisition import store
  16. from core.config import Settings
  17. ROOT = Path(__file__).resolve().parent.parent
  18. EXTRACT_PROMPT = ROOT / "prompts" / "extract.txt"
  19. WORKERS = 10 # 并发路数(实测线性提速;过高易被 OpenRouter 限流)
  20. MAX_IMG = 2 # 每帖最多喂几张图(控延迟/体积)
  21. RETRIES = 3 # 失败重试次数(含 429 限流退避)
  22. def _definition() -> str:
  23. """复用 extract.txt 的 <什么算创作知识> 段(同一判据,只读不改)。"""
  24. txt = EXTRACT_PROMPT.read_text("utf-8")
  25. a, b = txt.find("<什么算创作知识>"), txt.find("</什么算创作知识>")
  26. return txt[a:b + len("</什么算创作知识>")] if a >= 0 and b >= 0 else ""
  27. SYS_TMPL = """你是创作知识分类器。给你一篇帖子的平台/标题/正文和图片,判断它是不是「创作知识」。
  28. {definition}
  29. 只输出 JSON:{{"is_creation": true/false, "reason": "一句话理由(≤25字)"}}。
  30. 范围内但拿不准 → is_creation=true;明确越界(游戏玩法/产品运营/做菜本身/纯工具操作/纯作品展示)→ false。
  31. 注意:视频帖可能只有标题+封面、信息有限,据现有内容审慎判断。"""
  32. def _img_data_url(public_path: str):
  33. """本地公开路径 /data/search/... → base64 data URL;不存在返回 None。"""
  34. fs = ROOT / public_path.lstrip("/")
  35. if not fs.exists():
  36. return None
  37. return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
  38. def classify_one(post: dict, settings: Settings, system: str) -> dict:
  39. """单帖多模态判定,带重试。返回 {is_creation:1/0/None, reason}。None=判定失败(留待重跑)。"""
  40. user_text = (f"平台:{post.get('platform')}\n标题:{post.get('title') or ''}\n"
  41. f"正文:{(post.get('body_text') or '')[:600]}")
  42. content = [{"type": "text", "text": user_text}]
  43. for img in (post.get("images") or [])[:MAX_IMG]:
  44. url = _img_data_url(img)
  45. if url:
  46. content.append({"type": "image_url", "image_url": {"url": url}})
  47. messages = [{"role": "system", "content": system}, {"role": "user", "content": content}]
  48. api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
  49. headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
  50. payload = {"model": settings.llm_model, "messages": messages,
  51. "response_format": {"type": "json_object"}}
  52. last = ""
  53. for attempt in range(RETRIES):
  54. try:
  55. resp = httpx.post(api, headers=headers, json=payload, timeout=90)
  56. if resp.status_code == 200:
  57. d = json.loads(resp.json()["choices"][0]["message"]["content"])
  58. return {"is_creation": 1 if d.get("is_creation") else 0,
  59. "reason": str(d.get("reason", ""))[:50]}
  60. last = f"http {resp.status_code}"
  61. except Exception as exc:
  62. last = str(exc)[:50]
  63. time.sleep(1.5 * (attempt + 1)) # 退避后重试
  64. return {"is_creation": None, "reason": f"判定失败: {last}"}
  65. def main() -> None:
  66. settings = Settings.from_env()
  67. system = SYS_TMPL.format(definition=_definition())
  68. conn = store.connect()
  69. posts = store.get_unclassified_posts(conn)
  70. if not posts:
  71. print("没有待分类帖子(都判过了)。"); conn.close(); return
  72. print(f"待分类 {len(posts)} 帖(并发 {WORKERS},每帖≤{MAX_IMG} 图)...")
  73. ts, done, fail = int(time.time()), 0, 0
  74. with cf.ThreadPoolExecutor(max_workers=WORKERS) as ex:
  75. futs = {ex.submit(classify_one, p, settings, system): p for p in posts}
  76. for fut in cf.as_completed(futs):
  77. p = futs[fut]
  78. r = fut.result()
  79. if r["is_creation"] is None:
  80. fail += 1
  81. else:
  82. store.upsert_class(conn, p["url"], r["is_creation"], r["reason"], ts)
  83. done += 1
  84. if done % 25 == 0:
  85. print(f" {done}/{len(posts)}(失败 {fail})")
  86. c = store.class_counts(conn)
  87. conn.close()
  88. print(f"完成:创作知识 {c['creation']} / 非创作知识 {c['non_creation']}(本轮失败 {fail},可重跑补判)")
  89. if __name__ == "__main__":
  90. main()