1
0

writing_guidance_builder.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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. GENRE_METHOD_ANCHORS: dict[str, dict[str, str]] = {
  23. "xianxia": {
  24. "pressure_source": "资源争夺/境界压制",
  25. "release_target": "主角主动破局并拿到可见收益",
  26. },
  27. "urban-power": {
  28. "pressure_source": "阶层卡位/权力压制",
  29. "release_target": "主角通过资源博弈拿到地位与回报",
  30. },
  31. "romance": {
  32. "pressure_source": "关系误解/情感拉扯",
  33. "release_target": "关系位移落地并形成下一步承诺",
  34. },
  35. "mystery": {
  36. "pressure_source": "线索缺失/规则冲突",
  37. "release_target": "给出可验证的新线索并保留未知区",
  38. },
  39. "rules-mystery": {
  40. "pressure_source": "规则反噬/代价递增",
  41. "release_target": "用代价换突破并留下更高阶规则问题",
  42. },
  43. "zhihu-short": {
  44. "pressure_source": "信息落差/立场对撞",
  45. "release_target": "反转兑现并形成高强度尾钩",
  46. },
  47. "substitute": {
  48. "pressure_source": "身份误读/情绪对峙",
  49. "release_target": "误解链推进到明确决断",
  50. },
  51. "esports": {
  52. "pressure_source": "战术压制/节奏失衡",
  53. "release_target": "关键决策生效并转化为局势优势",
  54. },
  55. "livestream": {
  56. "pressure_source": "舆论波动/数据下滑",
  57. "release_target": "当场反制形成可见数据回弹",
  58. },
  59. "cosmic-horror": {
  60. "pressure_source": "认知失真/规则侵蚀",
  61. "release_target": "以明确代价换阶段性生存窗口",
  62. },
  63. "history-travel": {
  64. "pressure_source": "历史惯性/礼教阻力",
  65. "release_target": "知识优势兑现并引发新的连锁反应",
  66. },
  67. "game-lit": {
  68. "pressure_source": "系统规则限制/资源稀缺",
  69. "release_target": "数值突破并暴露更高层级威胁",
  70. },
  71. }
  72. def build_methodology_strategy_card(
  73. *,
  74. chapter: int,
  75. reader_signal: Dict[str, Any],
  76. genre_profile: Dict[str, Any],
  77. label: str = "digital-serial-v1",
  78. ) -> Dict[str, Any]:
  79. genre = str(genre_profile.get("genre") or "").strip()
  80. profile_key = to_profile_key(genre) or "general"
  81. hook_usage = reader_signal.get("hook_type_usage") or {}
  82. pattern_usage = reader_signal.get("pattern_usage") or {}
  83. review_trend = reader_signal.get("review_trend") or {}
  84. low_ranges = reader_signal.get("low_score_ranges") or []
  85. dominant_hook = ""
  86. if isinstance(hook_usage, dict) and hook_usage:
  87. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  88. dominant_pattern = ""
  89. if isinstance(pattern_usage, dict) and pattern_usage:
  90. dominant_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  91. overall_avg = float(review_trend.get("overall_avg") or 0.0)
  92. has_low_range = bool(low_ranges)
  93. hook_variety = len(hook_usage) if isinstance(hook_usage, dict) else 0
  94. pattern_variety = len(pattern_usage) if isinstance(pattern_usage, dict) else 0
  95. next_reason_clarity = 70.0 + (4.0 if has_low_range else 8.0)
  96. anchor_effectiveness = 68.0 + (6.0 if dominant_hook else 0.0) + (4.0 if overall_avg >= 75 else -4.0)
  97. rhythm_naturalness = 65.0 + min(10.0, float(hook_variety + pattern_variety) * 2.0)
  98. risk_flags: List[str] = []
  99. if has_low_range:
  100. risk_flags.append("low_score_recency")
  101. if dominant_pattern:
  102. risk_flags.append("pattern_overuse_watch")
  103. if overall_avg > 0 and overall_avg < 75:
  104. risk_flags.append("readability_guard")
  105. stage_mod = chapter % 5
  106. if stage_mod in {1, 2}:
  107. stage = "build_up"
  108. elif stage_mod in {3, 4}:
  109. stage = "confront"
  110. else:
  111. stage = "release"
  112. anchor_preset = GENRE_METHOD_ANCHORS.get(
  113. profile_key,
  114. {
  115. "pressure_source": "生存目标/资源竞争",
  116. "release_target": "主角完成阶段目标并留下新的行动理由",
  117. },
  118. )
  119. return {
  120. "enabled": True,
  121. "framework": label,
  122. "pilot": profile_key,
  123. "genre_profile_key": profile_key,
  124. "chapter_stage": stage,
  125. "emotion_anchor": {
  126. "pressure_source": anchor_preset["pressure_source"],
  127. "release_target": anchor_preset["release_target"],
  128. "position_hint": "前段设压,中后段释放,避免固定字位打点",
  129. },
  130. "long_arc_controls": {
  131. "map_transition": "阶段切换承接既有资产与关系账本,避免能力与收益归零",
  132. "power_guard": "关键胜利必须给机制理由(信息/资源/代价/策略)",
  133. "antagonist_model": "反派需具备目标-手段-代价三要素,避免工具人推进",
  134. },
  135. "serialization_ops": {
  136. "next_reason": "章末或后段给出可复述的下一章动机句",
  137. "interaction_note": "保留一个可讨论分歧点,便于连载互动反馈",
  138. },
  139. "observability": {
  140. "next_reason_clarity": round(max(0.0, min(100.0, next_reason_clarity)), 2),
  141. "anchor_effectiveness": round(max(0.0, min(100.0, anchor_effectiveness)), 2),
  142. "rhythm_naturalness": round(max(0.0, min(100.0, rhythm_naturalness)), 2),
  143. },
  144. "signals": {
  145. "dominant_hook": dominant_hook,
  146. "dominant_pattern": dominant_pattern,
  147. "risk_flags": risk_flags,
  148. },
  149. }
  150. def build_methodology_guidance_items(strategy_card: Dict[str, Any]) -> List[str]:
  151. if not isinstance(strategy_card, dict) or not strategy_card.get("enabled"):
  152. return []
  153. observability = strategy_card.get("observability") or {}
  154. signals = strategy_card.get("signals") or {}
  155. risk_flags = list(signals.get("risk_flags") or [])
  156. stage = str(strategy_card.get("chapter_stage") or "build_up")
  157. genre_key = str(strategy_card.get("genre_profile_key") or strategy_card.get("pilot") or "general")
  158. stage_text = {
  159. "build_up": "本章以铺压为主,优先做威胁与代价的可感知铺垫。",
  160. "confront": "本章以正面对抗为主,确保破局路径清晰可复盘。",
  161. "release": "本章以释放与余波为主,给出实质收益并引出下一问。",
  162. }.get(stage, "本章保持压力-破局-余波的完整链路。")
  163. items = [
  164. f"方法论策略(通用/{genre_key}):{stage_text}",
  165. "长线控制:换图承接旧资产,避免主角进入新地图后能力与资源归零。",
  166. "机制控制:关键胜利必须写出机制理由与代价,不用纯光环碾压。",
  167. (
  168. "连载互动:保留一个可讨论分歧点,强化下章追更动机。"
  169. f"(next_reason={observability.get('next_reason_clarity')})"
  170. ),
  171. ]
  172. if "pattern_overuse_watch" in risk_flags:
  173. dominant_pattern = str(signals.get("dominant_pattern") or "").strip()
  174. if dominant_pattern:
  175. items.append(f"风险修正:近期“{dominant_pattern}”偏高频,本章补一个异质副轴避免疲劳。")
  176. if "readability_guard" in risk_flags:
  177. items.append("风险修正:近期审查均分偏低,本章优先保证段落动作-结果闭环与可读性。")
  178. return items
  179. def build_guidance_items(
  180. *,
  181. chapter: int,
  182. reader_signal: Dict[str, Any],
  183. genre_profile: Dict[str, Any],
  184. low_score_threshold: float,
  185. hook_diversify_enabled: bool,
  186. ) -> Dict[str, Any]:
  187. guidance: List[str] = []
  188. low_ranges = reader_signal.get("low_score_ranges") or []
  189. if low_ranges:
  190. worst = min(
  191. low_ranges,
  192. key=lambda row: float(row.get("overall_score", 9999)),
  193. )
  194. guidance.append(
  195. f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
  196. )
  197. hook_usage = reader_signal.get("hook_type_usage") or {}
  198. if hook_usage and hook_diversify_enabled:
  199. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  200. guidance.append(
  201. f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
  202. )
  203. pattern_usage = reader_signal.get("pattern_usage") or {}
  204. if pattern_usage:
  205. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  206. guidance.append(
  207. f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
  208. )
  209. review_trend = reader_signal.get("review_trend") or {}
  210. overall_avg = review_trend.get("overall_avg")
  211. if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
  212. guidance.append(
  213. f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
  214. )
  215. genre = str(genre_profile.get("genre") or "").strip()
  216. refs = genre_profile.get("reference_hints") or []
  217. if genre:
  218. guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
  219. if refs:
  220. guidance.append(f"题材策略可执行提示:{refs[0]}")
  221. guidance.append("网文节奏基线:章首300字内给出目标与阻力,章末保留未闭合问题。")
  222. guidance.append("兑现密度基线:每600-900字给一次微兑现,并确保本章至少1处可量化变化。")
  223. normalized_genre = to_profile_key(genre)
  224. genre_hint = GENRE_GUIDANCE_TEXT.get(normalized_genre)
  225. if genre_hint:
  226. guidance.append(genre_hint)
  227. composite_hints = genre_profile.get("composite_hints") or []
  228. if composite_hints:
  229. guidance.append(f"复合题材协同:{composite_hints[0]}")
  230. if not guidance:
  231. guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
  232. return {
  233. "guidance": guidance,
  234. "low_ranges": low_ranges,
  235. "hook_usage": hook_usage,
  236. "pattern_usage": pattern_usage,
  237. "genre": genre,
  238. }
  239. def build_writing_checklist(
  240. *,
  241. guidance_items: List[str],
  242. reader_signal: Dict[str, Any],
  243. genre_profile: Dict[str, Any],
  244. strategy_card: Dict[str, Any] | None = None,
  245. min_items: int,
  246. max_items: int,
  247. default_weight: float,
  248. ) -> List[Dict[str, Any]]:
  249. items: List[Dict[str, Any]] = []
  250. def _add_item(
  251. item_id: str,
  252. label: str,
  253. *,
  254. weight: float | None = None,
  255. required: bool = False,
  256. source: str = "writing_guidance",
  257. verify_hint: str = "",
  258. ) -> None:
  259. if len(items) >= max_items:
  260. return
  261. if any(row.get("id") == item_id for row in items):
  262. return
  263. item_weight = float(weight if weight is not None else default_weight)
  264. if item_weight <= 0:
  265. item_weight = default_weight
  266. items.append(
  267. {
  268. "id": item_id,
  269. "label": label,
  270. "weight": round(item_weight, 2),
  271. "required": bool(required),
  272. "source": source,
  273. "verify_hint": verify_hint,
  274. }
  275. )
  276. low_ranges = reader_signal.get("low_score_ranges") or []
  277. if low_ranges:
  278. worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
  279. span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
  280. _add_item(
  281. "fix_low_score_range",
  282. f"修复低分区间问题(参考第{span}章)",
  283. weight=max(default_weight, 1.4),
  284. required=True,
  285. source="reader_signal.low_score_ranges",
  286. verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
  287. )
  288. hook_usage = reader_signal.get("hook_type_usage") or {}
  289. if hook_usage:
  290. dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
  291. _add_item(
  292. "hook_diversification",
  293. f"钩子差异化(避免继续单一“{dominant_hook}”)",
  294. weight=max(default_weight, 1.2),
  295. required=True,
  296. source="reader_signal.hook_type_usage",
  297. verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
  298. )
  299. pattern_usage = reader_signal.get("pattern_usage") or {}
  300. if pattern_usage:
  301. top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
  302. _add_item(
  303. "coolpoint_combo",
  304. f"主爽点+副爽点组合(主爽点:{top_pattern})",
  305. weight=default_weight,
  306. required=False,
  307. source="reader_signal.pattern_usage",
  308. verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
  309. )
  310. review_trend = reader_signal.get("review_trend") or {}
  311. overall_avg = review_trend.get("overall_avg")
  312. if isinstance(overall_avg, (int, float)):
  313. _add_item(
  314. "readability_loop",
  315. "段落可读性闭环(动作→结果→情绪)",
  316. weight=max(default_weight, 1.1),
  317. required=True,
  318. source="reader_signal.review_trend",
  319. verify_hint="抽查3段,均包含动作结果闭环。",
  320. )
  321. genre = str(genre_profile.get("genre") or "").strip()
  322. if genre:
  323. _add_item(
  324. "genre_anchor_consistency",
  325. f"题材锚定一致性({genre})",
  326. weight=max(default_weight, 1.1),
  327. required=True,
  328. source="genre_profile.genre",
  329. verify_hint="主冲突与题材核心承诺保持一致。",
  330. )
  331. if isinstance(strategy_card, dict) and strategy_card.get("enabled"):
  332. _add_item(
  333. "methodology_next_reason",
  334. "方法论:下章动机需可复述(章末或后段均可)",
  335. weight=default_weight,
  336. required=False,
  337. source="methodology.next_reason",
  338. verify_hint="提炼一句“为什么要点下一章”的动机句。",
  339. )
  340. _add_item(
  341. "methodology_power_guard",
  342. "方法论:越级与破局给出机制理由与代价",
  343. weight=default_weight,
  344. required=False,
  345. source="methodology.power_guard",
  346. verify_hint="至少写清1个机制理由与1个代价。"
  347. )
  348. _add_item(
  349. "methodology_antagonist_pressure",
  350. "方法论:反派行动具备目标-手段-代价",
  351. weight=default_weight,
  352. required=False,
  353. source="methodology.antagonist",
  354. verify_hint="反派不是工具人推进,需有可解释行动逻辑。",
  355. )
  356. for idx, text in enumerate(guidance_items, start=1):
  357. if len(items) >= max_items:
  358. break
  359. label = str(text).strip()
  360. if not label:
  361. continue
  362. _add_item(
  363. f"guidance_item_{idx}",
  364. label,
  365. weight=default_weight,
  366. required=False,
  367. source="writing_guidance.guidance_items",
  368. verify_hint="完成后可在正文中定位对应段落。",
  369. )
  370. fallback_items = [
  371. (
  372. "opening_conflict",
  373. "开篇300字内给出冲突触发",
  374. "开头段出现明确目标与阻力。",
  375. ),
  376. (
  377. "scene_goal_block",
  378. "场景目标与阻力清晰",
  379. "每个场景至少有1个可验证目标。",
  380. ),
  381. (
  382. "ending_hook",
  383. "段末留钩并引出下一问",
  384. "结尾出现未解问题或下一步行动。",
  385. ),
  386. ]
  387. for item_id, label, verify_hint in fallback_items:
  388. if len(items) >= min_items or len(items) >= max_items:
  389. break
  390. _add_item(
  391. item_id,
  392. label,
  393. weight=default_weight,
  394. required=False,
  395. source="fallback",
  396. verify_hint=verify_hint,
  397. )
  398. return items[:max_items]
  399. def is_checklist_item_completed(item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
  400. item_id = str(item.get("id") or "")
  401. if item_id in {"fix_low_score_range", "readability_loop"}:
  402. review_trend = reader_signal.get("review_trend") or {}
  403. overall = review_trend.get("overall_avg")
  404. return isinstance(overall, (int, float)) and float(overall) >= 75.0
  405. if item_id == "hook_diversification":
  406. hook_usage = reader_signal.get("hook_type_usage") or {}
  407. return len(hook_usage) >= 2
  408. if item_id == "coolpoint_combo":
  409. pattern_usage = reader_signal.get("pattern_usage") or {}
  410. return len(pattern_usage) >= 2
  411. if item_id == "genre_anchor_consistency":
  412. return True
  413. source = str(item.get("source") or "")
  414. if source.startswith("fallback"):
  415. return True
  416. if source.startswith("methodology."):
  417. # 方法论条目当前作为软提示,仅做观察与引导,不参与扣分。
  418. return True
  419. return False