Files
novelmaster/noma/scripts/data_modules/writing_guidance_builder.py
T
2026-06-23 20:29:02 +08:00

479 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Writing guidance and checklist builders.
"""
from __future__ import annotations
from typing import Any, Dict, List
from .genre_aliases import to_profile_key
GENRE_GUIDANCE_TEXT: dict[str, str] = {
"xianxia": "题材加权:强化升级/对抗结果的可见反馈,术语解释后置。",
"shuangwen": "题材加权:维持高爽点密度,主爽点外叠加一个副轴反差。",
"urban-power": "题材加权:优先写社会反馈链(他人反应→资源变化→地位变化)。",
"romance": "题材加权:每章推进关系位移,避免情绪原地打转。",
"mystery": "题材加权:线索必须可回收,优先以规则冲突制造悬念。",
"rules-mystery": "题材加权:规则先于解释,代价先于胜利。",
"zhihu-short": "题材加权:压缩铺垫,优先反转与高强度结尾钩。",
"substitute": "题材加权:强化误解-拉扯-决断链路,避免重复虐点。",
"esports": "题材加权:每场对抗至少写清一个战术决策点与其后果。",
"livestream": "题材加权:强化“外部反馈→主角反制→数据变化”即时闭环。",
"cosmic-horror": "题材加权:恐怖来源于规则与代价,不依赖空泛惊悚形容。",
}
GENRE_METHOD_ANCHORS: dict[str, dict[str, str]] = {
"xianxia": {
"pressure_source": "资源争夺/境界压制",
"release_target": "主角主动破局并拿到可见收益",
},
"urban-power": {
"pressure_source": "阶层卡位/权力压制",
"release_target": "主角通过资源博弈拿到地位与回报",
},
"romance": {
"pressure_source": "关系误解/情感拉扯",
"release_target": "关系位移落地并形成下一步承诺",
},
"mystery": {
"pressure_source": "线索缺失/规则冲突",
"release_target": "给出可验证的新线索并保留未知区",
},
"rules-mystery": {
"pressure_source": "规则反噬/代价递增",
"release_target": "用代价换突破并留下更高阶规则问题",
},
"zhihu-short": {
"pressure_source": "信息落差/立场对撞",
"release_target": "反转兑现并形成高强度尾钩",
},
"substitute": {
"pressure_source": "身份误读/情绪对峙",
"release_target": "误解链推进到明确决断",
},
"esports": {
"pressure_source": "战术压制/节奏失衡",
"release_target": "关键决策生效并转化为局势优势",
},
"livestream": {
"pressure_source": "舆论波动/数据下滑",
"release_target": "当场反制形成可见数据回弹",
},
"cosmic-horror": {
"pressure_source": "认知失真/规则侵蚀",
"release_target": "以明确代价换阶段性生存窗口",
},
"history-travel": {
"pressure_source": "历史惯性/礼教阻力",
"release_target": "知识优势兑现并引发新的连锁反应",
},
"game-lit": {
"pressure_source": "系统规则限制/资源稀缺",
"release_target": "数值突破并暴露更高层级威胁",
},
}
def build_methodology_strategy_card(
*,
chapter: int,
reader_signal: Dict[str, Any],
genre_profile: Dict[str, Any],
label: str = "digital-serial-v1",
) -> Dict[str, Any]:
genre = str(genre_profile.get("genre") or "").strip()
profile_key = to_profile_key(genre) or "general"
hook_usage = reader_signal.get("hook_type_usage") or {}
pattern_usage = reader_signal.get("pattern_usage") or {}
review_trend = reader_signal.get("review_trend") or {}
low_ranges = reader_signal.get("low_score_ranges") or []
dominant_hook = ""
if isinstance(hook_usage, dict) and hook_usage:
dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
dominant_pattern = ""
if isinstance(pattern_usage, dict) and pattern_usage:
dominant_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
overall_avg = float(review_trend.get("overall_avg") or 0.0)
has_low_range = bool(low_ranges)
hook_variety = len(hook_usage) if isinstance(hook_usage, dict) else 0
pattern_variety = len(pattern_usage) if isinstance(pattern_usage, dict) else 0
next_reason_clarity = 70.0 + (4.0 if has_low_range else 8.0)
anchor_effectiveness = 68.0 + (6.0 if dominant_hook else 0.0) + (4.0 if overall_avg >= 75 else -4.0)
rhythm_naturalness = 65.0 + min(10.0, float(hook_variety + pattern_variety) * 2.0)
risk_flags: List[str] = []
if has_low_range:
risk_flags.append("low_score_recency")
if dominant_pattern:
risk_flags.append("pattern_overuse_watch")
if overall_avg > 0 and overall_avg < 75:
risk_flags.append("readability_guard")
stage_mod = chapter % 5
if stage_mod in {1, 2}:
stage = "build_up"
elif stage_mod in {3, 4}:
stage = "confront"
else:
stage = "release"
anchor_preset = GENRE_METHOD_ANCHORS.get(
profile_key,
{
"pressure_source": "生存目标/资源竞争",
"release_target": "主角完成阶段目标并留下新的行动理由",
},
)
return {
"enabled": True,
"framework": label,
"pilot": profile_key,
"genre_profile_key": profile_key,
"chapter_stage": stage,
"emotion_anchor": {
"pressure_source": anchor_preset["pressure_source"],
"release_target": anchor_preset["release_target"],
"position_hint": "前段设压,中后段释放,避免固定字位打点",
},
"long_arc_controls": {
"map_transition": "阶段切换承接既有资产与关系账本,避免能力与收益归零",
"power_guard": "关键胜利必须给机制理由(信息/资源/代价/策略)",
"antagonist_model": "反派需具备目标-手段-代价三要素,避免工具人推进",
},
"serialization_ops": {
"next_reason": "章末或后段给出可复述的下一章动机句",
"interaction_note": "保留一个可讨论分歧点,便于连载互动反馈",
},
"observability": {
"next_reason_clarity": round(max(0.0, min(100.0, next_reason_clarity)), 2),
"anchor_effectiveness": round(max(0.0, min(100.0, anchor_effectiveness)), 2),
"rhythm_naturalness": round(max(0.0, min(100.0, rhythm_naturalness)), 2),
},
"signals": {
"dominant_hook": dominant_hook,
"dominant_pattern": dominant_pattern,
"risk_flags": risk_flags,
},
}
def build_methodology_guidance_items(strategy_card: Dict[str, Any]) -> List[str]:
if not isinstance(strategy_card, dict) or not strategy_card.get("enabled"):
return []
observability = strategy_card.get("observability") or {}
signals = strategy_card.get("signals") or {}
risk_flags = list(signals.get("risk_flags") or [])
stage = str(strategy_card.get("chapter_stage") or "build_up")
genre_key = str(strategy_card.get("genre_profile_key") or strategy_card.get("pilot") or "general")
stage_text = {
"build_up": "本章以铺压为主,优先做威胁与代价的可感知铺垫。",
"confront": "本章以正面对抗为主,确保破局路径清晰可复盘。",
"release": "本章以释放与余波为主,给出实质收益并引出下一问。",
}.get(stage, "本章保持压力-破局-余波的完整链路。")
items = [
f"方法论策略(通用/{genre_key}):{stage_text}",
"长线控制:换图承接旧资产,避免主角进入新地图后能力与资源归零。",
"机制控制:关键胜利必须写出机制理由与代价,不用纯光环碾压。",
(
"连载互动:保留一个可讨论分歧点,强化下章追更动机。"
f"next_reason={observability.get('next_reason_clarity')}"
),
]
if "pattern_overuse_watch" in risk_flags:
dominant_pattern = str(signals.get("dominant_pattern") or "").strip()
if dominant_pattern:
items.append(f"风险修正:近期“{dominant_pattern}”偏高频,本章补一个异质副轴避免疲劳。")
if "readability_guard" in risk_flags:
items.append("风险修正:近期审查均分偏低,本章优先保证段落动作-结果闭环与可读性。")
return items
def build_guidance_items(
*,
chapter: int,
reader_signal: Dict[str, Any],
genre_profile: Dict[str, Any],
low_score_threshold: float,
hook_diversify_enabled: bool,
) -> Dict[str, Any]:
guidance: List[str] = []
low_ranges = reader_signal.get("low_score_ranges") or []
if low_ranges:
worst = min(
low_ranges,
key=lambda row: float(row.get("overall_score", 9999)),
)
guidance.append(
f"第{chapter}章优先修复近期低分段问题:参考{worst.get('start_chapter')}-{worst.get('end_chapter')}章,强化冲突推进与结尾钩子。"
)
hook_usage = reader_signal.get("hook_type_usage") or {}
if hook_usage and hook_diversify_enabled:
dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
guidance.append(
f"近期钩子类型“{dominant_hook}”使用偏多,本章建议做钩子差异化,避免连续同构。"
)
pattern_usage = reader_signal.get("pattern_usage") or {}
if pattern_usage:
top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
guidance.append(
f"爽点模式“{top_pattern}”近期高频,本章可保留主爽点但叠加一个新爽点副轴。"
)
review_trend = reader_signal.get("review_trend") or {}
overall_avg = review_trend.get("overall_avg")
if isinstance(overall_avg, (int, float)) and float(overall_avg) < low_score_threshold:
guidance.append(
f"最近审查均分{overall_avg:.1f}低于阈值{low_score_threshold:.1f},建议先保稳:减少跳场、每段补动作结果闭环。"
)
genre = str(genre_profile.get("genre") or "").strip()
refs = genre_profile.get("reference_hints") or []
if genre:
guidance.append(f"题材锚定:按“{genre}”叙事主线推进,保持题材读者预期稳定兑现。")
if refs:
guidance.append(f"题材策略可执行提示:{refs[0]}")
guidance.append("网文节奏基线:章首300字内给出目标与阻力,章末保留未闭合问题。")
guidance.append("兑现密度基线:每600-900字给一次微兑现,并确保本章至少1处可量化变化。")
normalized_genre = to_profile_key(genre)
genre_hint = GENRE_GUIDANCE_TEXT.get(normalized_genre)
if genre_hint:
guidance.append(genre_hint)
composite_hints = genre_profile.get("composite_hints") or []
if composite_hints:
guidance.append(f"复合题材协同:{composite_hints[0]}")
if not guidance:
guidance.append("本章执行默认高可读策略:冲突前置、信息后置、段末留钩。")
return {
"guidance": guidance,
"low_ranges": low_ranges,
"hook_usage": hook_usage,
"pattern_usage": pattern_usage,
"genre": genre,
}
def build_writing_checklist(
*,
guidance_items: List[str],
reader_signal: Dict[str, Any],
genre_profile: Dict[str, Any],
strategy_card: Dict[str, Any] | None = None,
min_items: int,
max_items: int,
default_weight: float,
) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
def _add_item(
item_id: str,
label: str,
*,
weight: float | None = None,
required: bool = False,
source: str = "writing_guidance",
verify_hint: str = "",
) -> None:
if len(items) >= max_items:
return
if any(row.get("id") == item_id for row in items):
return
item_weight = float(weight if weight is not None else default_weight)
if item_weight <= 0:
item_weight = default_weight
items.append(
{
"id": item_id,
"label": label,
"weight": round(item_weight, 2),
"required": bool(required),
"source": source,
"verify_hint": verify_hint,
}
)
low_ranges = reader_signal.get("low_score_ranges") or []
if low_ranges:
worst = min(low_ranges, key=lambda row: float(row.get("overall_score", 9999)))
span = f"{worst.get('start_chapter')}-{worst.get('end_chapter')}"
_add_item(
"fix_low_score_range",
f"修复低分区间问题(参考第{span}章)",
weight=max(default_weight, 1.4),
required=True,
source="reader_signal.low_score_ranges",
verify_hint="至少完成1处冲突升级,并在段末留下钩子。",
)
hook_usage = reader_signal.get("hook_type_usage") or {}
if hook_usage:
dominant_hook = max(hook_usage.items(), key=lambda kv: kv[1])[0]
_add_item(
"hook_diversification",
f"钩子差异化(避免继续单一“{dominant_hook}”)",
weight=max(default_weight, 1.2),
required=True,
source="reader_signal.hook_type_usage",
verify_hint="结尾钩子类型与近20章主类型至少有一处差异。",
)
pattern_usage = reader_signal.get("pattern_usage") or {}
if pattern_usage:
top_pattern = max(pattern_usage.items(), key=lambda kv: kv[1])[0]
_add_item(
"coolpoint_combo",
f"主爽点+副爽点组合(主爽点:{top_pattern}",
weight=default_weight,
required=False,
source="reader_signal.pattern_usage",
verify_hint="新增至少1个副爽点,并与主爽点形成因果链。",
)
review_trend = reader_signal.get("review_trend") or {}
overall_avg = review_trend.get("overall_avg")
if isinstance(overall_avg, (int, float)):
_add_item(
"readability_loop",
"段落可读性闭环(动作→结果→情绪)",
weight=max(default_weight, 1.1),
required=True,
source="reader_signal.review_trend",
verify_hint="抽查3段,均包含动作结果闭环。",
)
genre = str(genre_profile.get("genre") or "").strip()
if genre:
_add_item(
"genre_anchor_consistency",
f"题材锚定一致性({genre}",
weight=max(default_weight, 1.1),
required=True,
source="genre_profile.genre",
verify_hint="主冲突与题材核心承诺保持一致。",
)
if isinstance(strategy_card, dict) and strategy_card.get("enabled"):
_add_item(
"methodology_next_reason",
"方法论:下章动机需可复述(章末或后段均可)",
weight=default_weight,
required=False,
source="methodology.next_reason",
verify_hint="提炼一句“为什么要点下一章”的动机句。",
)
_add_item(
"methodology_power_guard",
"方法论:越级与破局给出机制理由与代价",
weight=default_weight,
required=False,
source="methodology.power_guard",
verify_hint="至少写清1个机制理由与1个代价。"
)
_add_item(
"methodology_antagonist_pressure",
"方法论:反派行动具备目标-手段-代价",
weight=default_weight,
required=False,
source="methodology.antagonist",
verify_hint="反派不是工具人推进,需有可解释行动逻辑。",
)
for idx, text in enumerate(guidance_items, start=1):
if len(items) >= max_items:
break
label = str(text).strip()
if not label:
continue
_add_item(
f"guidance_item_{idx}",
label,
weight=default_weight,
required=False,
source="writing_guidance.guidance_items",
verify_hint="完成后可在正文中定位对应段落。",
)
fallback_items = [
(
"opening_conflict",
"开篇300字内给出冲突触发",
"开头段出现明确目标与阻力。",
),
(
"scene_goal_block",
"场景目标与阻力清晰",
"每个场景至少有1个可验证目标。",
),
(
"ending_hook",
"段末留钩并引出下一问",
"结尾出现未解问题或下一步行动。",
),
]
for item_id, label, verify_hint in fallback_items:
if len(items) >= min_items or len(items) >= max_items:
break
_add_item(
item_id,
label,
weight=default_weight,
required=False,
source="fallback",
verify_hint=verify_hint,
)
return items[:max_items]
def is_checklist_item_completed(item: Dict[str, Any], reader_signal: Dict[str, Any]) -> bool:
item_id = str(item.get("id") or "")
if item_id in {"fix_low_score_range", "readability_loop"}:
review_trend = reader_signal.get("review_trend") or {}
overall = review_trend.get("overall_avg")
return isinstance(overall, (int, float)) and float(overall) >= 75.0
if item_id == "hook_diversification":
hook_usage = reader_signal.get("hook_type_usage") or {}
return len(hook_usage) >= 2
if item_id == "coolpoint_combo":
pattern_usage = reader_signal.get("pattern_usage") or {}
return len(pattern_usage) >= 2
if item_id == "genre_anchor_consistency":
return True
source = str(item.get("source") or "")
if source.startswith("fallback"):
return True
if source.startswith("methodology."):
# 方法论条目当前作为软提示,仅做观察与引导,不参与扣分。
return True
return False