writing_guidance_builder.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Writing guidance and checklist builders.
  5. """
  6. from __future__ import annotations
  7. from typing import Any, Dict, List
  8. from .genre_aliases import to_profile_key
  9. GENRE_GUIDANCE_TEXT: dict[str, str] = {
  10. "xianxia": "题材加权:强化升级/对抗结果的可见反馈,术语解释后置。",
  11. "shuangwen": "题材加权:维持高爽点密度,主爽点外叠加一个副轴反差。",
  12. "urban-power": "题材加权:优先写社会反馈链(他人反应→资源变化→地位变化)。",
  13. "romance": "题材加权:每章推进关系位移,避免情绪原地打转。",
  14. "mystery": "题材加权:线索必须可回收,优先以规则冲突制造悬念。",
  15. "rules-mystery": "题材加权:规则先于解释,代价先于胜利。",
  16. "zhihu-short": "题材加权:压缩铺垫,优先反转与高强度结尾钩。",
  17. "substitute": "题材加权:强化误解-拉扯-决断链路,避免重复虐点。",
  18. "esports": "题材加权:每场对抗至少写清一个战术决策点与其后果。",
  19. "livestream": "题材加权:强化“外部反馈→主角反制→数据变化”即时闭环。",
  20. "cosmic-horror": "题材加权:恐怖来源于规则与代价,不依赖空泛惊悚形容。",
  21. }
  22. def build_guidance_items(
  23. *,
  24. chapter: int,
  25. reader_signal: Dict[str, Any],
  26. genre_profile: Dict[str, Any],
  27. low_score_threshold: float,
  28. hook_diversify_enabled: bool,
  29. ) -> Dict[str, Any]:
  30. guidance: List[str] = []
  31. low_ranges = reader_signal.get("low_score_ranges") or []
  32. if low_ranges:
  33. worst = min(
  34. low_ranges,
  35. key=lambda row: float(row.get("overall_score", 9999)),
  36. )
  37. guidance.append(
  38. f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
  39. )
  40. hook_usage = reader_signal.get("hook_type_usage") or {}
  41. if hook_usage and hook_diversify_enabled:
  42. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  43. guidance.append(
  44. f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
  45. )
  46. pattern_usage = reader_signal.get("pattern_usage") or {}
  47. if pattern_usage:
  48. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  49. guidance.append(
  50. f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
  51. )
  52. review_trend = reader_signal.get("review_trend") or {}
  53. overall_avg = review_trend.get("overall_avg")
  54. if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
  55. guidance.append(
  56. f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
  57. )
  58. genre = str(genre_profile.get("genre") or "").strip()
  59. refs = genre_profile.get("reference_hints") or []
  60. if genre:
  61. guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
  62. if refs:
  63. guidance.append(f"题材策略可执行提示:{refs[0]}")
  64. guidance.append("网文节奏基线:章首300字内给出目标与阻力,章末保留未闭合问题。")
  65. guidance.append("兑现密度基线:每600-900字给一次微兑现,并确保本章至少1处可量化变化。")
  66. normalized_genre = to_profile_key(genre)
  67. genre_hint = GENRE_GUIDANCE_TEXT.get(normalized_genre)
  68. if genre_hint:
  69. guidance.append(genre_hint)
  70. composite_hints = genre_profile.get("composite_hints") or []
  71. if composite_hints:
  72. guidance.append(f"复合题材协同:{composite_hints[0]}")
  73. if not guidance:
  74. guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
  75. return {
  76. "guidance": guidance,
  77. "low_ranges": low_ranges,
  78. "hook_usage": hook_usage,
  79. "pattern_usage": pattern_usage,
  80. "genre": genre,
  81. }
  82. def build_writing_checklist(
  83. *,
  84. guidance_items: List[str],
  85. reader_signal: Dict[str, Any],
  86. genre_profile: Dict[str, Any],
  87. min_items: int,
  88. max_items: int,
  89. default_weight: float,
  90. ) -> List[Dict[str, Any]]:
  91. items: List[Dict[str, Any]] = []
  92. def _add_item(
  93. item_id: str,
  94. label: str,
  95. *,
  96. weight: float | None = None,
  97. required: bool = False,
  98. source: str = "writing_guidance",
  99. verify_hint: str = "",
  100. ) -> None:
  101. if len(items) >= max_items:
  102. return
  103. if any(row.get("id") == item_id for row in items):
  104. return
  105. item_weight = float(weight if weight is not None else default_weight)
  106. if item_weight <= 0:
  107. item_weight = default_weight
  108. items.append(
  109. {
  110. "id": item_id,
  111. "label": label,
  112. "weight": round(item_weight, 2),
  113. "required": bool(required),
  114. "source": source,
  115. "verify_hint": verify_hint,
  116. }
  117. )
  118. low_ranges = reader_signal.get("low_score_ranges") or []
  119. if low_ranges:
  120. worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
  121. span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
  122. _add_item(
  123. "fix_low_score_range",
  124. f"修复低分区间问题(参考第{span}章)",
  125. weight=max(default_weight, 1.4),
  126. required=True,
  127. source="reader_signal.low_score_ranges",
  128. verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
  129. )
  130. hook_usage = reader_signal.get("hook_type_usage") or {}
  131. if hook_usage:
  132. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  133. _add_item(
  134. "hook_diversification",
  135. f"钩子差异化(避免继续单一“{dominant_hook}”)",
  136. weight=max(default_weight, 1.2),
  137. required=True,
  138. source="reader_signal.hook_type_usage",
  139. verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
  140. )
  141. pattern_usage = reader_signal.get("pattern_usage") or {}
  142. if pattern_usage:
  143. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  144. _add_item(
  145. "coolpoint_combo",
  146. f"主爽点+副爽点组合(主爽点:{top_pattern})",
  147. weight=default_weight,
  148. required=False,
  149. source="reader_signal.pattern_usage",
  150. verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
  151. )
  152. review_trend = reader_signal.get("review_trend") or {}
  153. overall_avg = review_trend.get("overall_avg")
  154. if isinstance(overall_avg, (int, float)):
  155. _add_item(
  156. "readability_loop",
  157. "段落可读性闭环(动作→结果→情绪)",
  158. weight=max(default_weight, 1.1),
  159. required=True,
  160. source="reader_signal.review_trend",
  161. verify_hint="抽查3段,均包含动作结果闭环。",
  162. )
  163. genre = str(genre_profile.get("genre") or "").strip()
  164. if genre:
  165. _add_item(
  166. "genre_anchor_consistency",
  167. f"题材锚定一致性({genre})",
  168. weight=max(default_weight, 1.1),
  169. required=True,
  170. source="genre_profile.genre",
  171. verify_hint="主冲突与题材核心承诺保持一致。",
  172. )
  173. for idx, text in enumerate(guidance_items, start=1):
  174. if len(items) >= max_items:
  175. break
  176. label = str(text).strip()
  177. if not label:
  178. continue
  179. _add_item(
  180. f"guidance_item_{idx}",
  181. label,
  182. weight=default_weight,
  183. required=False,
  184. source="writing_guidance.guidance_items",
  185. verify_hint="完成后可在正文中定位对应段落。",
  186. )
  187. fallback_items = [
  188. (
  189. "opening_conflict",
  190. "开篇300字内给出冲突触发",
  191. "开头段出现明确目标与阻力。",
  192. ),
  193. (
  194. "scene_goal_block",
  195. "场景目标与阻力清晰",
  196. "每个场景至少有1个可验证目标。",
  197. ),
  198. (
  199. "ending_hook",
  200. "段末留钩并引出下一问",
  201. "结尾出现未解问题或下一步行动。",
  202. ),
  203. ]
  204. for item_id, label, verify_hint in fallback_items:
  205. if len(items) >= min_items or len(items) >= max_items:
  206. break
  207. _add_item(
  208. item_id,
  209. label,
  210. weight=default_weight,
  211. required=False,
  212. source="fallback",
  213. verify_hint=verify_hint,
  214. )
  215. return items[:max_items]
  216. def is_checklist_item_completed(item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
  217. item_id = str(item.get("id") or "")
  218. if item_id in {"fix_low_score_range", "readability_loop"}:
  219. review_trend = reader_signal.get("review_trend") or {}
  220. overall = review_trend.get("overall_avg")
  221. return isinstance(overall, (int, float)) and float(overall) >= 75.0
  222. if item_id == "hook_diversification":
  223. hook_usage = reader_signal.get("hook_type_usage") or {}
  224. return len(hook_usage) >= 2
  225. if item_id == "coolpoint_combo":
  226. pattern_usage = reader_signal.get("pattern_usage") or {}
  227. return len(pattern_usage) >= 2
  228. if item_id == "genre_anchor_consistency":
  229. return True
  230. source = str(item.get("source") or "")
  231. if source.startswith("fallback"):
  232. return True
  233. return False