feedback_collector.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. #!/usr/bin/env python3
  2. """
  3. feedback_collector.py — 结构化反馈收集器
  4. 反馈数据写入 evolution/session_state.json 和项目数据库。
  5. """
  6. import sys, os, re, json
  7. from pathlib import Path
  8. from datetime import datetime
  9. from collections import Counter
  10. from typing import Any
  11. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  12. from utils import (
  13. find_project_root, get_evolution_dir,
  14. load_evolution, save_evolution, load_project_db, save_project_db,
  15. log_evolution_event,
  16. )
  17. def collect_chapter_feedback(project: Path, chapter_num: int,
  18. scores: dict[str, int],
  19. prompt_used: str = "",
  20. issues: list[str] | None = None,
  21. notes: str = "") -> dict:
  22. db = load_project_db(project)
  23. entry = {
  24. "timestamp": datetime.now().isoformat(),
  25. "chapter": chapter_num,
  26. "scores": scores,
  27. "composite": sum(scores.values()) / len(scores) if scores else 0,
  28. "prompt_used_hash": hash(prompt_used) % 100000 if prompt_used else None,
  29. "prompt_used": prompt_used[:200] if prompt_used else "",
  30. "issues": issues or [],
  31. "notes": notes,
  32. }
  33. db.setdefault("feedback", []).append(entry)
  34. db.setdefault("meta", {}).setdefault("quality_scores", {})[str(chapter_num)] = entry["composite"]
  35. save_project_db(project, db)
  36. # 更新会话状态
  37. state = load_evolution("session_state.json") or {}
  38. state.setdefault("recent_chapters", []).append({
  39. "chapter": chapter_num,
  40. "composite": entry["composite"],
  41. "timestamp": entry["timestamp"],
  42. })
  43. # 只保留最近 20 章
  44. state["recent_chapters"] = state["recent_chapters"][-20:]
  45. state["total_feedback_count"] = state.get("total_feedback_count", 0) + 1
  46. save_evolution("session_state.json", state)
  47. # 写入事件日志(Event Sourcing — git 共享无冲突)
  48. if prompt_used:
  49. _write_feedback_event(project, chapter_num, scores, prompt_used)
  50. log_evolution_event(project, "章节反馈收集", {
  51. "章节": chapter_num,
  52. "综合评分": f"{entry['composite']:.0f}",
  53. "各维评分": ", ".join(f"{k}:{v}" for k, v in scores.items()),
  54. "问题数": len(issues or []),
  55. })
  56. return entry
  57. def _write_feedback_event(project: Path, chapter_num: int,
  58. scores: dict[str, int],
  59. prompt_used: str) -> None:
  60. """将反馈写入事件日志(追加写入,天然无 git 冲突)。
  61. 不再直接修改 population.json —— 改为写入不可变事件文件。
  62. population.json 通过 `evolve.sh --compile` 从事件编译生成。
  63. """
  64. from hashlib import md5 as _md5
  65. from utils import (find_project_genre, map_genre_to_inkos_id,
  66. load_genre_evolution)
  67. from event_store import write_events
  68. genre_name = find_project_genre(project)
  69. genre_id = map_genre_to_inkos_id(genre_name)
  70. # 匹配变体:从当前编译后的 population 中查找
  71. pop = load_genre_evolution(genre_id, "prompts/population.json")
  72. prompt_hash = _md5(prompt_used.encode()).hexdigest()[:12]
  73. prompt_key = prompt_used[:40] if len(prompt_used) > 40 else prompt_used
  74. matched_island = "writing"
  75. matched_id = prompt_hash
  76. if pop:
  77. for cat, pop_data in pop.items():
  78. for variant in pop_data.get("variants", []):
  79. tmpl = variant.get("template", "")
  80. if (variant.get("id") == prompt_hash
  81. or (prompt_key and prompt_key in tmpl)
  82. or (prompt_used and len(tmpl) >= 40 and tmpl[:40] in prompt_used)):
  83. matched_island = cat
  84. matched_id = variant.get("id", prompt_hash)
  85. break
  86. else:
  87. continue
  88. break
  89. event = {
  90. "type": "feedback",
  91. "chapter": chapter_num,
  92. "island": matched_island,
  93. "variant_id": matched_id,
  94. "variant_template": prompt_used, # 携带模板文本,编译时可重建缺失变体
  95. "scores": {k.upper(): v for k, v in scores.items()},
  96. }
  97. write_events(genre_id, [event])
  98. def parse_user_feedback(text: str) -> dict:
  99. feedback = {"sentiment": "neutral", "mentioned_dimensions": [],
  100. "mentioned_chapters": [], "action_items": []}
  101. pos_words = ["好", "不错", "很好", "精彩", "满意", "perfect", "great"]
  102. neg_words = ["差", "不好", "弱", "无聊", "平淡", "不行", "重写", "废弃"]
  103. pos_count = sum(1 for w in pos_words if w in text)
  104. neg_count = sum(1 for w in neg_words if w in text)
  105. if pos_count > neg_count:
  106. feedback["sentiment"] = "positive"
  107. elif neg_count > pos_count:
  108. feedback["sentiment"] = "negative"
  109. dim_keywords = {
  110. "结构": ["结构", "节奏", "场景", "布局"],
  111. "角色": ["角色", "人物", "主角", "配角", "人设"],
  112. "原创性": ["套路", "新鲜", "创新", "模板"],
  113. "对话": ["对话", "对白", "台词"],
  114. "文本": ["文字", "措辞", "句式", "描写"],
  115. "钩子": ["钩子", "悬念", "伏笔", "期待"],
  116. "爽点": ["爽点", "高潮", "打脸", "爽感"],
  117. "情感": ["情感", "情绪", "心理"],
  118. }
  119. for dim, keywords in dim_keywords.items():
  120. if any(kw in text for kw in keywords):
  121. feedback["mentioned_dimensions"].append(dim)
  122. chapter_refs = re.findall(r"第(\d+)章", text)
  123. feedback["mentioned_chapters"] = [int(c) for c in chapter_refs]
  124. for m in re.finditer(r"(?:需要|应该|必须|要)\s*(.+?)(?:[。!,,.]|$)", text):
  125. feedback["action_items"].append({"type": "required", "content": m.group(1).strip()})
  126. return feedback
  127. if __name__ == "__main__":
  128. import argparse
  129. parser = argparse.ArgumentParser(description="反馈收集器")
  130. parser.add_argument("action", choices=["collect", "parse", "state"])
  131. parser.add_argument("--project", "-p", default=".")
  132. parser.add_argument("--chapter", "-c", type=int)
  133. parser.add_argument("--scores", "-s", type=str, default="{}")
  134. parser.add_argument("--prompt", type=str, default="")
  135. parser.add_argument("--issues", "-i", nargs="*", default=[])
  136. parser.add_argument("--notes", "-n", type=str, default="")
  137. parser.add_argument("--text", "-t", type=str, default="")
  138. args = parser.parse_args()
  139. if args.action == "collect":
  140. root = find_project_root(Path(args.project).resolve())
  141. if not root:
  142. print("错误: 未找到小说项目根目录")
  143. sys.exit(1)
  144. if not args.chapter:
  145. print("错误: 需要 --chapter")
  146. sys.exit(1)
  147. try:
  148. scores = json.loads(args.scores)
  149. except json.JSONDecodeError:
  150. print("错误: --scores 需要 JSON 格式")
  151. sys.exit(1)
  152. result = collect_chapter_feedback(root, args.chapter, scores,
  153. prompt_used=args.prompt,
  154. issues=args.issues, notes=args.notes)
  155. print(f"✓ 章节 {args.chapter} 反馈已收集 (综合: {result['composite']:.0f})")
  156. elif args.action == "parse":
  157. if not args.text:
  158. print("错误: 需要 --text")
  159. sys.exit(1)
  160. result = parse_user_feedback(args.text)
  161. print(json.dumps(result, ensure_ascii=False, indent=2))
  162. elif args.action == "state":
  163. state = load_evolution("session_state.json") or {}
  164. print(json.dumps(state, ensure_ascii=False, indent=2))