#!/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